From c90d6c9853ab64cf94c2d3d016f615ae4376fa89 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 16 Jun 2025 14:11:10 +0100 Subject: [PATCH] add context with arguments to completable --- src/server/completable.ts | 3 + src/server/mcp.test.ts | 271 +++++++++++++++++++++++++++++++++++--- src/server/mcp.ts | 7 +- src/types.test.ts | 90 ++++++++++++- src/types.ts | 8 ++ 5 files changed, 357 insertions(+), 22 deletions(-) diff --git a/src/server/completable.ts b/src/server/completable.ts index 3b5bc164..652eaf72 100644 --- a/src/server/completable.ts +++ b/src/server/completable.ts @@ -15,6 +15,9 @@ export enum McpZodTypeKind { export type CompleteCallback = ( value: T["_input"], + context?: { + arguments?: Record; + }, ) => T["_input"][] | Promise; export interface CompletableDef diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 36a8f7b8..23600db4 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -3521,12 +3521,12 @@ describe("prompt()", () => { ); expect(result.resources).toHaveLength(2); - + // Resource 1 should have its own metadata expect(result.resources[0].name).toBe("Resource 1"); expect(result.resources[0].description).toBe("Individual resource description"); expect(result.resources[0].mimeType).toBe("text/plain"); - + // Resource 2 should inherit template metadata expect(result.resources[1].name).toBe("Resource 2"); expect(result.resources[1].description).toBe("Template description"); @@ -3592,7 +3592,7 @@ describe("prompt()", () => { ); expect(result.resources).toHaveLength(1); - + // All fields should be from the individual resource, not the template expect(result.resources[0].name).toBe("Overridden Name"); expect(result.resources[0].description).toBe("Overridden description"); @@ -3698,41 +3698,274 @@ describe("Tool title precedence", () => { }); test("getDisplayName unit tests for title precedence", () => { - + // Test 1: Only name expect(getDisplayName({ name: "tool_name" })).toBe("tool_name"); - + // Test 2: Name and title - title wins - expect(getDisplayName({ - name: "tool_name", - title: "Tool Title" + expect(getDisplayName({ + name: "tool_name", + title: "Tool Title" })).toBe("Tool Title"); - + // Test 3: Name and annotations.title - annotations.title wins - expect(getDisplayName({ + expect(getDisplayName({ name: "tool_name", annotations: { title: "Annotations Title" } })).toBe("Annotations Title"); - + // Test 4: All three - title wins (correct precedence) - expect(getDisplayName({ - name: "tool_name", + expect(getDisplayName({ + name: "tool_name", title: "Regular Title", annotations: { title: "Annotations Title" } })).toBe("Regular Title"); - + // Test 5: Empty title should not be used - expect(getDisplayName({ - name: "tool_name", + expect(getDisplayName({ + name: "tool_name", title: "", annotations: { title: "Annotations Title" } })).toBe("Annotations Title"); - + // Test 6: Undefined vs null handling - expect(getDisplayName({ - name: "tool_name", + expect(getDisplayName({ + name: "tool_name", title: undefined, annotations: { title: "Annotations Title" } })).toBe("Annotations Title"); }); + + test("should support resource template completion with resolved context", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + + const client = new Client({ + name: "test client", + version: "1.0", + }); + + mcpServer.resource( + "test", + new ResourceTemplate("github://repos/{owner}/{repo}", { + list: undefined, + complete: { + repo: (value, context) => { + if (context?.arguments?.["owner"] === "org1") { + return ["project1", "project2", "project3"].filter(r => r.startsWith(value)); + } else if (context?.arguments?.["owner"] === "org2") { + return ["repo1", "repo2", "repo3"].filter(r => r.startsWith(value)); + } + return []; + }, + }, + }), + async () => ({ + contents: [ + { + uri: "github://repos/test/test", + text: "Test content", + }, + ], + }), + ); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + // Test with microsoft owner + const result1 = await client.request( + { + method: "completion/complete", + params: { + ref: { + type: "ref/resource", + uri: "github://repos/{owner}/{repo}", + }, + argument: { + name: "repo", + value: "p", + }, + context: { + arguments: { + owner: "org1", + }, + }, + }, + }, + CompleteResultSchema, + ); + + expect(result1.completion.values).toEqual(["project1", "project2", "project3"]); + expect(result1.completion.total).toBe(3); + + // Test with facebook owner + const result2 = await client.request( + { + method: "completion/complete", + params: { + ref: { + type: "ref/resource", + uri: "github://repos/{owner}/{repo}", + }, + argument: { + name: "repo", + value: "r", + }, + context: { + arguments: { + owner: "org2", + }, + }, + }, + }, + CompleteResultSchema, + ); + + expect(result2.completion.values).toEqual(["repo1", "repo2", "repo3"]); + expect(result2.completion.total).toBe(3); + + // Test with no resolved context + const result3 = await client.request( + { + method: "completion/complete", + params: { + ref: { + type: "ref/resource", + uri: "github://repos/{owner}/{repo}", + }, + argument: { + name: "repo", + value: "t", + }, + }, + }, + CompleteResultSchema, + ); + + expect(result3.completion.values).toEqual([]); + expect(result3.completion.total).toBe(0); + }); + + test("should support prompt argument completion with resolved context", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + + const client = new Client({ + name: "test client", + version: "1.0", + }); + + mcpServer.prompt( + "test-prompt", + { + name: completable(z.string(), (value, context) => { + if (context?.arguments?.["category"] === "developers") { + return ["Alice", "Bob", "Charlie"].filter(n => n.startsWith(value)); + } else if (context?.arguments?.["category"] === "managers") { + return ["David", "Eve", "Frank"].filter(n => n.startsWith(value)); + } + return ["Guest"].filter(n => n.startsWith(value)); + }), + }, + async ({ name }) => ({ + messages: [ + { + role: "assistant", + content: { + type: "text", + text: `Hello ${name}`, + }, + }, + ], + }), + ); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + // Test with developers category + const result1 = await client.request( + { + method: "completion/complete", + params: { + ref: { + type: "ref/prompt", + name: "test-prompt", + }, + argument: { + name: "name", + value: "A", + }, + context: { + arguments: { + category: "developers", + }, + }, + }, + }, + CompleteResultSchema, + ); + + expect(result1.completion.values).toEqual(["Alice"]); + + // Test with managers category + const result2 = await client.request( + { + method: "completion/complete", + params: { + ref: { + type: "ref/prompt", + name: "test-prompt", + }, + argument: { + name: "name", + value: "D", + }, + context: { + arguments: { + category: "managers", + }, + }, + }, + }, + CompleteResultSchema, + ); + + expect(result2.completion.values).toEqual(["David"]); + + // Test with no resolved context + const result3 = await client.request( + { + method: "completion/complete", + params: { + ref: { + type: "ref/prompt", + name: "test-prompt", + }, + argument: { + name: "name", + value: "G", + }, + }, + }, + CompleteResultSchema, + ); + + expect(result3.completion.values).toEqual(["Guest"]); + }); }); diff --git a/src/server/mcp.ts b/src/server/mcp.ts index ac6cf772..3d9673da 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -293,7 +293,7 @@ export class McpServer { } const def: CompletableDef = field._def; - const suggestions = await def.complete(request.params.argument.value); + const suggestions = await def.complete(request.params.argument.value, request.params.context); return createCompletionResult(suggestions); } @@ -324,7 +324,7 @@ export class McpServer { return EMPTY_COMPLETION_RESULT; } - const suggestions = await completer(request.params.argument.value); + const suggestions = await completer(request.params.argument.value, request.params.context); return createCompletionResult(suggestions); } @@ -1068,6 +1068,9 @@ export class McpServer { */ export type CompleteResourceTemplateCallback = ( value: string, + context?: { + arguments?: Record; + }, ) => string[] | Promise; /** diff --git a/src/types.test.ts b/src/types.test.ts index d163f03d..bc109110 100644 --- a/src/types.test.ts +++ b/src/types.test.ts @@ -4,7 +4,8 @@ import { ResourceLinkSchema, ContentBlockSchema, PromptMessageSchema, - CallToolResultSchema + CallToolResultSchema, + CompleteRequestSchema } from "./types.js"; describe("Types", () => { @@ -223,4 +224,91 @@ describe("Types", () => { } }); }); + + describe("CompleteRequest", () => { + test("should validate a CompleteRequest without resolved field", () => { + const request = { + method: "completion/complete", + params: { + ref: { type: "ref/prompt", name: "greeting" }, + argument: { name: "name", value: "A" } + } + }; + + const result = CompleteRequestSchema.safeParse(request); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.method).toBe("completion/complete"); + expect(result.data.params.ref.type).toBe("ref/prompt"); + expect(result.data.params.context).toBeUndefined(); + } + }); + + test("should validate a CompleteRequest with resolved field", () => { + const request = { + method: "completion/complete", + params: { + ref: { type: "ref/resource", uri: "github://repos/{owner}/{repo}" }, + argument: { name: "repo", value: "t" }, + context: { + arguments: { + "{owner}": "microsoft" + } + } + } + }; + + const result = CompleteRequestSchema.safeParse(request); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.params.context?.arguments).toEqual({ + "{owner}": "microsoft" + }); + } + }); + + test("should validate a CompleteRequest with empty resolved field", () => { + const request = { + method: "completion/complete", + params: { + ref: { type: "ref/prompt", name: "test" }, + argument: { name: "arg", value: "" }, + context: { + arguments: {} + } + } + }; + + const result = CompleteRequestSchema.safeParse(request); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.params.context?.arguments).toEqual({}); + } + }); + + test("should validate a CompleteRequest with multiple resolved variables", () => { + const request = { + method: "completion/complete", + params: { + ref: { type: "ref/resource", uri: "api://v1/{tenant}/{resource}/{id}" }, + argument: { name: "id", value: "123" }, + context: { + arguments: { + "{tenant}": "acme-corp", + "{resource}": "users" + } + } + } + }; + + const result = CompleteRequestSchema.safeParse(request); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.params.context?.arguments).toEqual({ + "{tenant}": "acme-corp", + "{resource}": "users" + }); + } + }); + }); }); diff --git a/src/types.ts b/src/types.ts index 8e8b7d33..1bc22591 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1169,6 +1169,14 @@ export const CompleteRequestSchema = RequestSchema.extend({ value: z.string(), }) .passthrough(), + context: z.optional( + z.object({ + /** + * Previously-resolved variables in a URI template or prompt. + */ + arguments: z.optional(z.record(z.string(), z.string())), + }) + ), }), });