diff --git a/src/server.ts b/src/server.ts index 78131c5..c9c2228 100644 --- a/src/server.ts +++ b/src/server.ts @@ -25,8 +25,12 @@ export function createServer({ client, appUrl }: ServerDependencies): McpServer version: __VERSION__, }); - server.registerTool("list_documents", listDocumentsConfig, listDocumentsHandler(client)); - server.registerTool("search_documents", searchDocumentsConfig, searchDocumentsHandler(client)); + server.registerTool("list_documents", listDocumentsConfig, listDocumentsHandler(client, appUrl)); + server.registerTool( + "search_documents", + searchDocumentsConfig, + searchDocumentsHandler(client, appUrl), + ); server.registerTool( "get_document_content", getDocumentContentConfig, diff --git a/src/tools/create-document-from-template.ts b/src/tools/create-document-from-template.ts index 09b1a80..74c4178 100644 --- a/src/tools/create-document-from-template.ts +++ b/src/tools/create-document-from-template.ts @@ -5,7 +5,12 @@ import type { CreateDocumentRequest, CreateDocumentResponse, } from "../contractbook/client.js"; -import { dataFieldSchema, errorMessage, signatureVerificationMethods } from "./shared.js"; +import { + dataFieldSchema, + documentUrl, + errorMessage, + signatureVerificationMethods, +} from "./shared.js"; const signingOrderModes = ["random", "strict", "grouped"] as const; @@ -183,11 +188,6 @@ export function buildBody(args: CreateDocumentFromTemplateArgs): CreateDocumentR return { document }; } -// The app's draft route is /draft/{encoded-title}/{id}; /documents/{id} redirects to the contract list. -function draftUrl(appUrl: string, title: string | undefined, id: string): string { - return `${appUrl}/draft/${encodeURIComponent(title ?? "Untitled")}/${id}`; -} - function formatCreatedDocumentResponse(response: CreateDocumentResponse, appUrl: string) { const document = response.document; if (!document) { @@ -196,7 +196,7 @@ function formatCreatedDocumentResponse(response: CreateDocumentResponse, appUrl: return { document: { id: document.id, - url: document.id ? draftUrl(appUrl, document.title, document.id) : undefined, + url: documentUrl(appUrl, { state: "draft", ...document }), title: document.title, state: document.state, type: document.type, diff --git a/src/tools/list-documents.ts b/src/tools/list-documents.ts index 14a8f61..04d7e02 100644 --- a/src/tools/list-documents.ts +++ b/src/tools/list-documents.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import type { ContractbookClient, ListDocumentsResponse } from "../contractbook/client.js"; -import { errorMessage } from "./shared.js"; +import { documentUrl, errorMessage } from "./shared.js"; const documentStates = [ "draft", @@ -21,6 +21,8 @@ export const listDocumentsConfig = { description: "Lists documents with filtering, sorting and cursor-based pagination. " + "Use the title filter to search documents by title (case-insensitive partial match). " + + "Each document includes its url in the Contractbook app; use it whenever you link to a document " + + "and never build document links yourself. " + "To fetch the next page, pass the cursor returned by the previous call.", inputSchema: z.object({ title: z @@ -88,12 +90,14 @@ export interface ListDocumentsArgs { sort_direction?: "asc" | "desc"; } -export function listDocumentsHandler(client: ContractbookClient) { +export function listDocumentsHandler(client: ContractbookClient, appUrl: string) { return async (args: ListDocumentsArgs) => { try { const response = await client.listDocuments(buildParams(args)); return { - content: [{ type: "text" as const, text: JSON.stringify(formatListResponse(response)) }], + content: [ + { type: "text" as const, text: JSON.stringify(formatListResponse(response, appUrl)) }, + ], }; } catch (error) { return { @@ -150,10 +154,11 @@ export function buildParams(args: ListDocumentsArgs): Record ({ id: document.id, + url: documentUrl(appUrl, document), title: document.title, state: document.state, type: document.type, diff --git a/src/tools/search-documents.ts b/src/tools/search-documents.ts index aceefb0..f9d266d 100644 --- a/src/tools/search-documents.ts +++ b/src/tools/search-documents.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import type { ContractbookClient, SearchDocumentsResponse } from "../contractbook/client.js"; -import { errorMessage } from "./shared.js"; +import { documentUrl, errorMessage } from "./shared.js"; export const searchDocumentsConfig = { title: "Search Documents", @@ -10,6 +10,8 @@ export const searchDocumentsConfig = { "Returns documents ranked by relevance, with matching text snippets. " + "Matched terms in snippets are wrapped in <@|...|@> markers. " + "Use this for finding documents by what they say; use list_documents for structured filtering. " + + "Each document includes its url in the Contractbook app; use it whenever you link to a document " + + "and never build document links yourself. " + "To fetch the next page, pass the cursor returned by the previous call.", inputSchema: z.object({ term: z.string().min(3).max(500).describe("Search query (3-500 characters)"), @@ -43,12 +45,14 @@ export interface SearchDocumentsArgs { sort_direction?: "asc" | "desc"; } -export function searchDocumentsHandler(client: ContractbookClient) { +export function searchDocumentsHandler(client: ContractbookClient, appUrl: string) { return async (args: SearchDocumentsArgs) => { try { const response = await client.searchDocuments(buildParams(args)); return { - content: [{ type: "text" as const, text: JSON.stringify(formatSearchResponse(response)) }], + content: [ + { type: "text" as const, text: JSON.stringify(formatSearchResponse(response, appUrl)) }, + ], }; } catch (error) { return { @@ -78,10 +82,11 @@ export function buildParams(args: SearchDocumentsArgs): Record ({ id: document.id, + url: documentUrl(appUrl, document), title: document.title, state: document.state, type: document.type, diff --git a/src/tools/shared.ts b/src/tools/shared.ts index 8c332e4..2e3f25a 100644 --- a/src/tools/shared.ts +++ b/src/tools/shared.ts @@ -57,3 +57,23 @@ export function formatDataField(field: TemplateDataField) { formatting: field.formatting, }; } + +export function documentUrl( + appUrl: string, + document: { id?: string; title?: string | null; state?: string; type?: string }, +): string | undefined { + if (!document.id) { + return undefined; + } + const route = + document.state === "draft" + ? "draft" + : document.type === "stored_contract" + ? "stored-contract" + : "contract"; + return `${appUrl}/${route}/${encodeDocumentTitle(document.title)}/${document.id}`; +} + +function encodeDocumentTitle(title: string | null | undefined): string { + return encodeURIComponent((title || "Untitled").replace(/%/g, "-").replace(/#/g, "")); +} diff --git a/tests/list-documents.test.ts b/tests/list-documents.test.ts index 5a91a42..1d72978 100644 --- a/tests/list-documents.test.ts +++ b/tests/list-documents.test.ts @@ -8,6 +8,8 @@ import { import { fakeFetch } from "./helpers/fake-fetch.js"; import { makeClient } from "./helpers/make-client.js"; +const appUrl = "https://app.test"; + describe("listDocumentsConfig", () => { it("accepts an empty input", () => { expect(listDocumentsConfig.inputSchema.parse({})).toEqual({}); @@ -52,7 +54,7 @@ describe("listDocumentsHandler", () => { response: { documents: [], pagination_meta: { cursor: null } }, }, ]); - const handler = listDocumentsHandler(makeClient(fetchImpl)); + const handler = listDocumentsHandler(makeClient(fetchImpl), appUrl); await handler({ title: "employment", states: ["pending", "signed"], page_size: 10 }); @@ -102,7 +104,7 @@ describe("listDocumentsHandler", () => { }, }, ]); - const handler = listDocumentsHandler(makeClient(fetchImpl)); + const handler = listDocumentsHandler(makeClient(fetchImpl), appUrl); const result = await handler({}); expect(result.isError).toBeUndefined(); @@ -110,6 +112,7 @@ describe("listDocumentsHandler", () => { expect(parsed.cursor).toBe("bmV4dA"); const document = parsed.documents[0]; expect(document.id).toBe("doc-1"); + expect(document.url).toBe("https://app.test/contract/Test/doc-1"); expect(document.state).toBe("pending"); expect(document.comment_count).toBeUndefined(); expect(document.owner).toEqual({ full_name: "Jane Doe", email: "jane@example.com" }); @@ -121,6 +124,43 @@ describe("listDocumentsHandler", () => { }); }); + it("links each document to the app route for its state and type", async () => { + const { fetchImpl } = fakeFetch([ + { + method: "GET", + path: "/v3/documents", + response: { + documents: [ + { id: "doc-1", title: "NDA", state: "draft", type: "draft" }, + { id: "doc-2", title: "Lease", state: "signed", type: "stored_contract" }, + { + id: "doc-3", + title: "Contractbook x VI - Order Confermation", + state: "signed", + type: "contract", + }, + { id: "doc-4", title: "50% off #1", state: "pending", type: "contract" }, + { id: "doc-5", title: null, state: "draft", type: "draft" }, + ], + pagination_meta: { cursor: null }, + }, + }, + ]); + const handler = listDocumentsHandler(makeClient(fetchImpl), appUrl); + + const result = await handler({}); + const urls = JSON.parse(result.content[0].text).documents.map( + (document: { url: string }) => document.url, + ); + expect(urls).toEqual([ + "https://app.test/draft/NDA/doc-1", + "https://app.test/stored-contract/Lease/doc-2", + "https://app.test/contract/Contractbook%20x%20VI%20-%20Order%20Confermation/doc-3", + "https://app.test/contract/50-%20off%201/doc-4", + "https://app.test/draft/Untitled/doc-5", + ]); + }); + it("returns isError on HTTP failure", async () => { const { fetchImpl } = fakeFetch([ { @@ -130,7 +170,7 @@ describe("listDocumentsHandler", () => { response: { error: "unauthorized" }, }, ]); - const handler = listDocumentsHandler(makeClient(fetchImpl)); + const handler = listDocumentsHandler(makeClient(fetchImpl), appUrl); const result = await handler({}); expect(result.isError).toBe(true); diff --git a/tests/search-documents.test.ts b/tests/search-documents.test.ts index 131a551..6fdcf5e 100644 --- a/tests/search-documents.test.ts +++ b/tests/search-documents.test.ts @@ -8,6 +8,8 @@ import { import { fakeFetch } from "./helpers/fake-fetch.js"; import { makeClient } from "./helpers/make-client.js"; +const appUrl = "https://app.test"; + describe("searchDocumentsConfig", () => { it("requires a term", () => { expect(() => searchDocumentsConfig.inputSchema.parse({})).toThrow(); @@ -59,7 +61,7 @@ describe("searchDocumentsHandler", () => { response: { documents: [], pagination_meta: { cursor: null } }, }, ]); - const handler = searchDocumentsHandler(makeClient(fetchImpl)); + const handler = searchDocumentsHandler(makeClient(fetchImpl), appUrl); await handler({ term: "agreement", page_size: 5 }); @@ -106,7 +108,7 @@ describe("searchDocumentsHandler", () => { }, }, ]); - const handler = searchDocumentsHandler(makeClient(fetchImpl)); + const handler = searchDocumentsHandler(makeClient(fetchImpl), appUrl); const result = await handler({ term: "agreement" }); expect(result.isError).toBeUndefined(); @@ -114,6 +116,7 @@ describe("searchDocumentsHandler", () => { expect(parsed.cursor).toBe("bmV4dA"); const document = parsed.documents[0]; expect(document.id).toBe("doc-1"); + expect(document.url).toBe("https://app.test/contract/Test%20Agreement/doc-1"); expect(document.version).toBeUndefined(); expect(document.owner).toEqual({ full_name: "Jane Doe", email: "jane@example.com" }); expect(document.parties[0]).toEqual({ @@ -134,7 +137,7 @@ describe("searchDocumentsHandler", () => { response: { error: "unauthorized" }, }, ]); - const handler = searchDocumentsHandler(makeClient(fetchImpl)); + const handler = searchDocumentsHandler(makeClient(fetchImpl), appUrl); const result = await handler({ term: "agreement" }); expect(result.isError).toBe(true);