add context with arguments to completable

This commit is contained in:
ihrpr 2025-06-16 14:11:10 +01:00
commit c90d6c9853
No known key found for this signature in database
5 changed files with 357 additions and 22 deletions

View file

@ -15,6 +15,9 @@ export enum McpZodTypeKind {
export type CompleteCallback<T extends ZodTypeAny = ZodTypeAny> = (
value: T["_input"],
context?: {
arguments?: Record<string, string>;
},
) => T["_input"][] | Promise<T["_input"][]>;
export interface CompletableDef<T extends ZodTypeAny = ZodTypeAny>

View file

@ -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"]);
});
});

View file

@ -293,7 +293,7 @@ export class McpServer {
}
const def: CompletableDef<ZodString> = 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, string>;
},
) => string[] | Promise<string[]>;
/**

View file

@ -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"
});
}
});
});
});

View file

@ -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())),
})
),
}),
});