Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 7 additions & 7 deletions src/tools/create-document-from-template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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) {
Expand All @@ -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,
Expand Down
13 changes: 9 additions & 4 deletions src/tools/list-documents.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -150,10 +154,11 @@ export function buildParams(args: ListDocumentsArgs): Record<string, string | st
return params;
}

function formatListResponse(response: ListDocumentsResponse) {
function formatListResponse(response: ListDocumentsResponse, appUrl: string) {
return {
documents: (response.documents ?? []).map((document) => ({
id: document.id,
url: documentUrl(appUrl, document),
title: document.title,
state: document.state,
type: document.type,
Expand Down
13 changes: 9 additions & 4 deletions src/tools/search-documents.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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)"),
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -78,10 +82,11 @@ export function buildParams(args: SearchDocumentsArgs): Record<string, string |
return params;
}

function formatSearchResponse(response: SearchDocumentsResponse) {
function formatSearchResponse(response: SearchDocumentsResponse, appUrl: string) {
return {
documents: (response.documents ?? []).map((document) => ({
id: document.id,
url: documentUrl(appUrl, document),
title: document.title,
state: document.state,
type: document.type,
Expand Down
20 changes: 20 additions & 0 deletions src/tools/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, ""));
}
46 changes: 43 additions & 3 deletions tests/list-documents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({});
Expand Down Expand Up @@ -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 });

Expand Down Expand Up @@ -102,14 +104,15 @@ describe("listDocumentsHandler", () => {
},
},
]);
const handler = listDocumentsHandler(makeClient(fetchImpl));
const handler = listDocumentsHandler(makeClient(fetchImpl), appUrl);

const result = await handler({});
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
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" });
Expand All @@ -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([
{
Expand All @@ -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);
Expand Down
9 changes: 6 additions & 3 deletions tests/search-documents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 });

Expand Down Expand Up @@ -106,14 +108,15 @@ describe("searchDocumentsHandler", () => {
},
},
]);
const handler = searchDocumentsHandler(makeClient(fetchImpl));
const handler = searchDocumentsHandler(makeClient(fetchImpl), appUrl);

const result = await handler({ term: "agreement" });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
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({
Expand All @@ -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);
Expand Down
Loading