Merge pull request #634 from modelcontextprotocol/ihrpr/context-for-completable
Include context into completions
This commit is contained in:
commit
e89137f21a
6 changed files with 490 additions and 23 deletions
95
README.md
95
README.md
|
|
@ -11,6 +11,7 @@
|
|||
- [Resources](#resources)
|
||||
- [Tools](#tools)
|
||||
- [Prompts](#prompts)
|
||||
- [Completions](#completions)
|
||||
- [Running Your Server](#running-your-server)
|
||||
- [stdio](#stdio)
|
||||
- [Streamable HTTP](#streamable-http)
|
||||
|
|
@ -150,6 +151,33 @@ server.registerResource(
|
|||
}]
|
||||
})
|
||||
);
|
||||
|
||||
// Resource with context-aware completion
|
||||
server.registerResource(
|
||||
"repository",
|
||||
new ResourceTemplate("github://repos/{owner}/{repo}", {
|
||||
list: undefined,
|
||||
complete: {
|
||||
// Provide intelligent completions based on previously resolved parameters
|
||||
repo: (value, context) => {
|
||||
if (context?.arguments?.["owner"] === "org1") {
|
||||
return ["project1", "project2", "project3"].filter(r => r.startsWith(value));
|
||||
}
|
||||
return ["default-repo"].filter(r => r.startsWith(value));
|
||||
}
|
||||
}
|
||||
}),
|
||||
{
|
||||
title: "GitHub Repository",
|
||||
description: "Repository information"
|
||||
},
|
||||
async (uri, { owner, repo }) => ({
|
||||
contents: [{
|
||||
uri: uri.href,
|
||||
text: `Repository: ${owner}/${repo}`
|
||||
}]
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
### Tools
|
||||
|
|
@ -233,12 +261,14 @@ Tools can return `ResourceLink` objects to reference resources without embedding
|
|||
Prompts are reusable templates that help LLMs interact with your server effectively:
|
||||
|
||||
```typescript
|
||||
import { completable } from "@modelcontextprotocol/sdk/server/completable.js";
|
||||
|
||||
server.registerPrompt(
|
||||
"review-code",
|
||||
{
|
||||
title: "Code Review",
|
||||
description: "Review code for best practices and potential issues",
|
||||
arguments: { code: z.string() }
|
||||
argsSchema: { code: z.string() }
|
||||
},
|
||||
({ code }) => ({
|
||||
messages: [{
|
||||
|
|
@ -250,6 +280,68 @@ server.registerPrompt(
|
|||
}]
|
||||
})
|
||||
);
|
||||
|
||||
// Prompt with context-aware completion
|
||||
server.registerPrompt(
|
||||
"team-greeting",
|
||||
{
|
||||
title: "Team Greeting",
|
||||
description: "Generate a greeting for team members",
|
||||
argsSchema: {
|
||||
department: completable(z.string(), (value) => {
|
||||
// Department suggestions
|
||||
return ["engineering", "sales", "marketing", "support"].filter(d => d.startsWith(value));
|
||||
}),
|
||||
name: completable(z.string(), (value, context) => {
|
||||
// Name suggestions based on selected department
|
||||
const department = context?.arguments?.["department"];
|
||||
if (department === "engineering") {
|
||||
return ["Alice", "Bob", "Charlie"].filter(n => n.startsWith(value));
|
||||
} else if (department === "sales") {
|
||||
return ["David", "Eve", "Frank"].filter(n => n.startsWith(value));
|
||||
} else if (department === "marketing") {
|
||||
return ["Grace", "Henry", "Iris"].filter(n => n.startsWith(value));
|
||||
}
|
||||
return ["Guest"].filter(n => n.startsWith(value));
|
||||
})
|
||||
}
|
||||
},
|
||||
({ department, name }) => ({
|
||||
messages: [{
|
||||
role: "assistant",
|
||||
content: {
|
||||
type: "text",
|
||||
text: `Hello ${name}, welcome to the ${department} team!`
|
||||
}
|
||||
}]
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
### Completions
|
||||
|
||||
MCP supports argument completions to help users fill in prompt arguments and resource template parameters. See the examples above for [resource completions](#resources) and [prompt completions](#prompts).
|
||||
|
||||
#### Client Usage
|
||||
|
||||
```typescript
|
||||
// Request completions for any argument
|
||||
const result = await client.complete({
|
||||
ref: {
|
||||
type: "ref/prompt", // or "ref/resource"
|
||||
name: "example" // or uri: "template://..."
|
||||
},
|
||||
argument: {
|
||||
name: "argumentName",
|
||||
value: "partial" // What the user has typed so far
|
||||
},
|
||||
context: { // Optional: Include previously resolved arguments
|
||||
arguments: {
|
||||
previousArg: "value"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
```
|
||||
|
||||
### Display Names and Metadata
|
||||
|
|
@ -805,6 +897,7 @@ const result = await client.callTool({
|
|||
arg1: "value"
|
||||
}
|
||||
});
|
||||
|
||||
```
|
||||
|
||||
### Proxy Authorization Requests Upstream
|
||||
|
|
|
|||
|
|
@ -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,313 @@ 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.registerResource(
|
||||
"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 [];
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
title: "GitHub Repository",
|
||||
description: "Repository information"
|
||||
},
|
||||
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.registerPrompt(
|
||||
"test-prompt",
|
||||
{
|
||||
title: "Team Greeting",
|
||||
description: "Generate a greeting for team members",
|
||||
argsSchema: {
|
||||
department: completable(z.string(), (value) => {
|
||||
return ["engineering", "sales", "marketing", "support"].filter(d => d.startsWith(value));
|
||||
}),
|
||||
name: completable(z.string(), (value, context) => {
|
||||
const department = context?.arguments?.["department"];
|
||||
if (department === "engineering") {
|
||||
return ["Alice", "Bob", "Charlie"].filter(n => n.startsWith(value));
|
||||
} else if (department === "sales") {
|
||||
return ["David", "Eve", "Frank"].filter(n => n.startsWith(value));
|
||||
} else if (department === "marketing") {
|
||||
return ["Grace", "Henry", "Iris"].filter(n => n.startsWith(value));
|
||||
}
|
||||
return ["Guest"].filter(n => n.startsWith(value));
|
||||
}),
|
||||
}
|
||||
},
|
||||
async ({ department, name }) => ({
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: {
|
||||
type: "text",
|
||||
text: `Hello ${name}, welcome to the ${department} team!`,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
|
||||
await Promise.all([
|
||||
client.connect(clientTransport),
|
||||
mcpServer.server.connect(serverTransport),
|
||||
]);
|
||||
|
||||
// Test with engineering department
|
||||
const result1 = await client.request(
|
||||
{
|
||||
method: "completion/complete",
|
||||
params: {
|
||||
ref: {
|
||||
type: "ref/prompt",
|
||||
name: "test-prompt",
|
||||
},
|
||||
argument: {
|
||||
name: "name",
|
||||
value: "A",
|
||||
},
|
||||
context: {
|
||||
arguments: {
|
||||
department: "engineering",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
CompleteResultSchema,
|
||||
);
|
||||
|
||||
expect(result1.completion.values).toEqual(["Alice"]);
|
||||
|
||||
// Test with sales department
|
||||
const result2 = await client.request(
|
||||
{
|
||||
method: "completion/complete",
|
||||
params: {
|
||||
ref: {
|
||||
type: "ref/prompt",
|
||||
name: "test-prompt",
|
||||
},
|
||||
argument: {
|
||||
name: "name",
|
||||
value: "D",
|
||||
},
|
||||
context: {
|
||||
arguments: {
|
||||
department: "sales",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
CompleteResultSchema,
|
||||
);
|
||||
|
||||
expect(result2.completion.values).toEqual(["David"]);
|
||||
|
||||
// Test with marketing department
|
||||
const result3 = await client.request(
|
||||
{
|
||||
method: "completion/complete",
|
||||
params: {
|
||||
ref: {
|
||||
type: "ref/prompt",
|
||||
name: "test-prompt",
|
||||
},
|
||||
argument: {
|
||||
name: "name",
|
||||
value: "G",
|
||||
},
|
||||
context: {
|
||||
arguments: {
|
||||
department: "marketing",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
CompleteResultSchema,
|
||||
);
|
||||
|
||||
expect(result3.completion.values).toEqual(["Grace"]);
|
||||
|
||||
// Test with no resolved context
|
||||
const result4 = await client.request(
|
||||
{
|
||||
method: "completion/complete",
|
||||
params: {
|
||||
ref: {
|
||||
type: "ref/prompt",
|
||||
name: "test-prompt",
|
||||
},
|
||||
argument: {
|
||||
name: "name",
|
||||
value: "G",
|
||||
},
|
||||
},
|
||||
},
|
||||
CompleteResultSchema,
|
||||
);
|
||||
|
||||
expect(result4.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