add context with arguments to completable
This commit is contained in:
parent
f822c1255e
commit
c90d6c9853
5 changed files with 357 additions and 22 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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[]>;
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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())),
|
||||
})
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue