Merge pull request #632 from modelcontextprotocol/ihrpr/resource-link
Add support for resource link
This commit is contained in:
commit
f822c1255e
5 changed files with 435 additions and 16 deletions
34
README.md
34
README.md
|
|
@ -192,8 +192,42 @@ server.registerTool(
|
|||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Tool that returns ResourceLinks
|
||||
server.registerTool(
|
||||
"list-files",
|
||||
{
|
||||
title: "List Files",
|
||||
description: "List project files",
|
||||
inputSchema: { pattern: z.string() }
|
||||
},
|
||||
async ({ pattern }) => ({
|
||||
content: [
|
||||
{ type: "text", text: `Found files matching "${pattern}":` },
|
||||
// ResourceLinks let tools return references without file content
|
||||
{
|
||||
type: "resource_link",
|
||||
uri: "file:///project/README.md",
|
||||
name: "README.md",
|
||||
mimeType: "text/markdown",
|
||||
description: 'A README file'
|
||||
},
|
||||
{
|
||||
type: "resource_link",
|
||||
uri: "file:///project/src/index.ts",
|
||||
name: "index.ts",
|
||||
mimeType: "text/typescript",
|
||||
description: 'An index file'
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
#### ResourceLinks
|
||||
|
||||
Tools can return `ResourceLink` objects to reference resources without embedding their full content. This is essential for performance when dealing with large files or many resources - clients can then selectively read only the resources they need using the provided URIs.
|
||||
|
||||
### Prompts
|
||||
|
||||
Prompts are reusable templates that help LLMs interact with your server effectively:
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ import {
|
|||
ListResourcesResultSchema,
|
||||
LoggingMessageNotificationSchema,
|
||||
ResourceListChangedNotificationSchema,
|
||||
ReadResourceRequest,
|
||||
ReadResourceResultSchema,
|
||||
ResourceLink,
|
||||
} from '../../types.js';
|
||||
import { getDisplayName } from '../../shared/metadataUtils.js';
|
||||
|
||||
|
|
@ -60,6 +63,7 @@ function printHelp(): void {
|
|||
console.log(' list-prompts - List available prompts');
|
||||
console.log(' get-prompt [name] [args] - Get a prompt with optional JSON arguments');
|
||||
console.log(' list-resources - List available resources');
|
||||
console.log(' read-resource <uri> - Read a specific resource by URI');
|
||||
console.log(' help - Show this help');
|
||||
console.log(' quit - Exit the program');
|
||||
}
|
||||
|
|
@ -155,6 +159,14 @@ function commandLoop(): void {
|
|||
await listResources();
|
||||
break;
|
||||
|
||||
case 'read-resource':
|
||||
if (args.length < 2) {
|
||||
console.log('Usage: read-resource <uri>');
|
||||
} else {
|
||||
await readResource(args[1]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'help':
|
||||
printHelp();
|
||||
break;
|
||||
|
|
@ -345,13 +357,37 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<vo
|
|||
const result = await client.request(request, CallToolResultSchema);
|
||||
|
||||
console.log('Tool result:');
|
||||
const resourceLinks: ResourceLink[] = [];
|
||||
|
||||
result.content.forEach(item => {
|
||||
if (item.type === 'text') {
|
||||
console.log(` ${item.text}`);
|
||||
} else if (item.type === 'resource_link') {
|
||||
const resourceLink = item as ResourceLink;
|
||||
resourceLinks.push(resourceLink);
|
||||
console.log(` 📁 Resource Link: ${resourceLink.name}`);
|
||||
console.log(` URI: ${resourceLink.uri}`);
|
||||
if (resourceLink.mimeType) {
|
||||
console.log(` Type: ${resourceLink.mimeType}`);
|
||||
}
|
||||
if (resourceLink.description) {
|
||||
console.log(` Description: ${resourceLink.description}`);
|
||||
}
|
||||
} else if (item.type === 'resource') {
|
||||
console.log(` [Embedded Resource: ${item.resource.uri}]`);
|
||||
} else if (item.type === 'image') {
|
||||
console.log(` [Image: ${item.mimeType}]`);
|
||||
} else if (item.type === 'audio') {
|
||||
console.log(` [Audio: ${item.mimeType}]`);
|
||||
} else {
|
||||
console.log(` ${item.type} content:`, item);
|
||||
console.log(` [Unknown content type]:`, item);
|
||||
}
|
||||
});
|
||||
|
||||
// Offer to read resource links
|
||||
if (resourceLinks.length > 0) {
|
||||
console.log(`\nFound ${resourceLinks.length} resource link(s). Use 'read-resource <uri>' to read their content.`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`Error calling tool ${name}: ${error}`);
|
||||
}
|
||||
|
|
@ -489,6 +525,42 @@ async function listResources(): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
async function readResource(uri: string): Promise<void> {
|
||||
if (!client) {
|
||||
console.log('Not connected to server.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const request: ReadResourceRequest = {
|
||||
method: 'resources/read',
|
||||
params: { uri }
|
||||
};
|
||||
|
||||
console.log(`Reading resource: ${uri}`);
|
||||
const result = await client.request(request, ReadResourceResultSchema);
|
||||
|
||||
console.log('Resource contents:');
|
||||
for (const content of result.contents) {
|
||||
console.log(` URI: ${content.uri}`);
|
||||
if (content.mimeType) {
|
||||
console.log(` Type: ${content.mimeType}`);
|
||||
}
|
||||
|
||||
if ('text' in content && typeof content.text === 'string') {
|
||||
console.log(' Content:');
|
||||
console.log(' ---');
|
||||
console.log(content.text.split('\n').map((line: string) => ' ' + line).join('\n'));
|
||||
console.log(' ---');
|
||||
} else if ('blob' in content && typeof content.blob === 'string') {
|
||||
console.log(` [Binary data: ${content.blob.length} bytes]`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`Error reading resource ${uri}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanup(): Promise<void> {
|
||||
if (client && transport) {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { McpServer } from '../../server/mcp.js';
|
|||
import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js';
|
||||
import { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter } from '../../server/auth/router.js';
|
||||
import { requireBearerAuth } from '../../server/auth/middleware/bearerAuth.js';
|
||||
import { CallToolResult, GetPromptResult, isInitializeRequest, ReadResourceResult } from '../../types.js';
|
||||
import { CallToolResult, GetPromptResult, isInitializeRequest, ReadResourceResult, ResourceLink } from '../../types.js';
|
||||
import { InMemoryEventStore } from '../shared/inMemoryEventStore.js';
|
||||
import { setupAuthServer } from './demoInMemoryOAuthProvider.js';
|
||||
import { OAuthMetadata } from 'src/shared/auth.js';
|
||||
|
|
@ -173,6 +173,99 @@ const getServer = () => {
|
|||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Create additional resources for ResourceLink demonstration
|
||||
server.registerResource(
|
||||
'example-file-1',
|
||||
'file:///example/file1.txt',
|
||||
{
|
||||
title: 'Example File 1',
|
||||
description: 'First example file for ResourceLink demonstration',
|
||||
mimeType: 'text/plain'
|
||||
},
|
||||
async (): Promise<ReadResourceResult> => {
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri: 'file:///example/file1.txt',
|
||||
text: 'This is the content of file 1',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.registerResource(
|
||||
'example-file-2',
|
||||
'file:///example/file2.txt',
|
||||
{
|
||||
title: 'Example File 2',
|
||||
description: 'Second example file for ResourceLink demonstration',
|
||||
mimeType: 'text/plain'
|
||||
},
|
||||
async (): Promise<ReadResourceResult> => {
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri: 'file:///example/file2.txt',
|
||||
text: 'This is the content of file 2',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Register a tool that returns ResourceLinks
|
||||
server.registerTool(
|
||||
'list-files',
|
||||
{
|
||||
title: 'List Files with ResourceLinks',
|
||||
description: 'Returns a list of files as ResourceLinks without embedding their content',
|
||||
inputSchema: {
|
||||
includeDescriptions: z.boolean().optional().describe('Whether to include descriptions in the resource links'),
|
||||
},
|
||||
},
|
||||
async ({ includeDescriptions = true }): Promise<CallToolResult> => {
|
||||
const resourceLinks: ResourceLink[] = [
|
||||
{
|
||||
type: 'resource_link',
|
||||
uri: 'https://example.com/greetings/default',
|
||||
name: 'Default Greeting',
|
||||
mimeType: 'text/plain',
|
||||
...(includeDescriptions && { description: 'A simple greeting resource' })
|
||||
},
|
||||
{
|
||||
type: 'resource_link',
|
||||
uri: 'file:///example/file1.txt',
|
||||
name: 'Example File 1',
|
||||
mimeType: 'text/plain',
|
||||
...(includeDescriptions && { description: 'First example file for ResourceLink demonstration' })
|
||||
},
|
||||
{
|
||||
type: 'resource_link',
|
||||
uri: 'file:///example/file2.txt',
|
||||
name: 'Example File 2',
|
||||
mimeType: 'text/plain',
|
||||
...(includeDescriptions && { description: 'Second example file for ResourceLink demonstration' })
|
||||
}
|
||||
];
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Here are the available files as resource links:',
|
||||
},
|
||||
...resourceLinks,
|
||||
{
|
||||
type: 'text',
|
||||
text: '\nYou can read any of these resources using their URI.',
|
||||
}
|
||||
],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
return server;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,11 @@
|
|||
import { LATEST_PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS } from "./types.js";
|
||||
import {
|
||||
LATEST_PROTOCOL_VERSION,
|
||||
SUPPORTED_PROTOCOL_VERSIONS,
|
||||
ResourceLinkSchema,
|
||||
ContentBlockSchema,
|
||||
PromptMessageSchema,
|
||||
CallToolResultSchema
|
||||
} from "./types.js";
|
||||
|
||||
describe("Types", () => {
|
||||
|
||||
|
|
@ -14,4 +21,206 @@ describe("Types", () => {
|
|||
expect(SUPPORTED_PROTOCOL_VERSIONS).toContain("2024-10-07");
|
||||
});
|
||||
|
||||
describe("ResourceLink", () => {
|
||||
test("should validate a minimal ResourceLink", () => {
|
||||
const resourceLink = {
|
||||
type: "resource_link",
|
||||
uri: "file:///path/to/file.txt",
|
||||
name: "file.txt"
|
||||
};
|
||||
|
||||
const result = ResourceLinkSchema.safeParse(resourceLink);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.type).toBe("resource_link");
|
||||
expect(result.data.uri).toBe("file:///path/to/file.txt");
|
||||
expect(result.data.name).toBe("file.txt");
|
||||
}
|
||||
});
|
||||
|
||||
test("should validate a ResourceLink with all optional fields", () => {
|
||||
const resourceLink = {
|
||||
type: "resource_link",
|
||||
uri: "https://example.com/resource",
|
||||
name: "Example Resource",
|
||||
title: "A comprehensive example resource",
|
||||
description: "This resource demonstrates all fields",
|
||||
mimeType: "text/plain",
|
||||
_meta: { custom: "metadata" }
|
||||
};
|
||||
|
||||
const result = ResourceLinkSchema.safeParse(resourceLink);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.title).toBe("A comprehensive example resource");
|
||||
expect(result.data.description).toBe("This resource demonstrates all fields");
|
||||
expect(result.data.mimeType).toBe("text/plain");
|
||||
expect(result.data._meta).toEqual({ custom: "metadata" });
|
||||
}
|
||||
});
|
||||
|
||||
test("should fail validation for invalid type", () => {
|
||||
const invalidResourceLink = {
|
||||
type: "invalid_type",
|
||||
uri: "file:///path/to/file.txt",
|
||||
name: "file.txt"
|
||||
};
|
||||
|
||||
const result = ResourceLinkSchema.safeParse(invalidResourceLink);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test("should fail validation for missing required fields", () => {
|
||||
const invalidResourceLink = {
|
||||
type: "resource_link",
|
||||
uri: "file:///path/to/file.txt"
|
||||
// missing name
|
||||
};
|
||||
|
||||
const result = ResourceLinkSchema.safeParse(invalidResourceLink);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContentBlock", () => {
|
||||
test("should validate text content", () => {
|
||||
const textContent = {
|
||||
type: "text",
|
||||
text: "Hello, world!"
|
||||
};
|
||||
|
||||
const result = ContentBlockSchema.safeParse(textContent);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.type).toBe("text");
|
||||
}
|
||||
});
|
||||
|
||||
test("should validate image content", () => {
|
||||
const imageContent = {
|
||||
type: "image",
|
||||
data: "aGVsbG8=", // base64 encoded "hello"
|
||||
mimeType: "image/png"
|
||||
};
|
||||
|
||||
const result = ContentBlockSchema.safeParse(imageContent);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.type).toBe("image");
|
||||
}
|
||||
});
|
||||
|
||||
test("should validate audio content", () => {
|
||||
const audioContent = {
|
||||
type: "audio",
|
||||
data: "aGVsbG8=", // base64 encoded "hello"
|
||||
mimeType: "audio/mp3"
|
||||
};
|
||||
|
||||
const result = ContentBlockSchema.safeParse(audioContent);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.type).toBe("audio");
|
||||
}
|
||||
});
|
||||
|
||||
test("should validate resource link content", () => {
|
||||
const resourceLink = {
|
||||
type: "resource_link",
|
||||
uri: "file:///path/to/file.txt",
|
||||
name: "file.txt",
|
||||
mimeType: "text/plain"
|
||||
};
|
||||
|
||||
const result = ContentBlockSchema.safeParse(resourceLink);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.type).toBe("resource_link");
|
||||
}
|
||||
});
|
||||
|
||||
test("should validate embedded resource content", () => {
|
||||
const embeddedResource = {
|
||||
type: "resource",
|
||||
resource: {
|
||||
uri: "file:///path/to/file.txt",
|
||||
mimeType: "text/plain",
|
||||
text: "File contents"
|
||||
}
|
||||
};
|
||||
|
||||
const result = ContentBlockSchema.safeParse(embeddedResource);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.type).toBe("resource");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("PromptMessage with ContentBlock", () => {
|
||||
test("should validate prompt message with resource link", () => {
|
||||
const promptMessage = {
|
||||
role: "assistant",
|
||||
content: {
|
||||
type: "resource_link",
|
||||
uri: "file:///project/src/main.rs",
|
||||
name: "main.rs",
|
||||
description: "Primary application entry point",
|
||||
mimeType: "text/x-rust"
|
||||
}
|
||||
};
|
||||
|
||||
const result = PromptMessageSchema.safeParse(promptMessage);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.content.type).toBe("resource_link");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("CallToolResult with ContentBlock", () => {
|
||||
test("should validate tool result with resource links", () => {
|
||||
const toolResult = {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Found the following files:"
|
||||
},
|
||||
{
|
||||
type: "resource_link",
|
||||
uri: "file:///project/src/main.rs",
|
||||
name: "main.rs",
|
||||
description: "Primary application entry point",
|
||||
mimeType: "text/x-rust"
|
||||
},
|
||||
{
|
||||
type: "resource_link",
|
||||
uri: "file:///project/src/lib.rs",
|
||||
name: "lib.rs",
|
||||
description: "Library exports",
|
||||
mimeType: "text/x-rust"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const result = CallToolResultSchema.safeParse(toolResult);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.content).toHaveLength(3);
|
||||
expect(result.data.content[0].type).toBe("text");
|
||||
expect(result.data.content[1].type).toBe("resource_link");
|
||||
expect(result.data.content[2].type).toBe("resource_link");
|
||||
}
|
||||
});
|
||||
|
||||
test("should validate empty content array with default", () => {
|
||||
const toolResult = {};
|
||||
|
||||
const result = CallToolResultSchema.safeParse(toolResult);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.content).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
37
src/types.ts
37
src/types.ts
|
|
@ -742,18 +742,33 @@ export const EmbeddedResourceSchema = z
|
|||
})
|
||||
.passthrough();
|
||||
|
||||
/**
|
||||
* A resource that the server is capable of reading, included in a prompt or tool call result.
|
||||
*
|
||||
* Note: resource links returned by tools are not guaranteed to appear in the results of `resources/list` requests.
|
||||
*/
|
||||
export const ResourceLinkSchema = ResourceSchema.extend({
|
||||
type: z.literal("resource_link"),
|
||||
});
|
||||
|
||||
/**
|
||||
* A content block that can be used in prompts and tool results.
|
||||
*/
|
||||
export const ContentBlockSchema = z.union([
|
||||
TextContentSchema,
|
||||
ImageContentSchema,
|
||||
AudioContentSchema,
|
||||
ResourceLinkSchema,
|
||||
EmbeddedResourceSchema,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Describes a message returned as part of a prompt.
|
||||
*/
|
||||
export const PromptMessageSchema = z
|
||||
.object({
|
||||
role: z.enum(["user", "assistant"]),
|
||||
content: z.union([
|
||||
TextContentSchema,
|
||||
ImageContentSchema,
|
||||
AudioContentSchema,
|
||||
EmbeddedResourceSchema,
|
||||
]),
|
||||
content: ContentBlockSchema,
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
|
|
@ -897,13 +912,7 @@ export const CallToolResultSchema = ResultSchema.extend({
|
|||
* If the Tool does not define an outputSchema, this field MUST be present in the result.
|
||||
* For backwards compatibility, this field is always present, but it may be empty.
|
||||
*/
|
||||
content: z.array(
|
||||
z.union([
|
||||
TextContentSchema,
|
||||
ImageContentSchema,
|
||||
AudioContentSchema,
|
||||
EmbeddedResourceSchema,
|
||||
])).default([]),
|
||||
content: z.array(ContentBlockSchema).default([]),
|
||||
|
||||
/**
|
||||
* An object containing structured tool output.
|
||||
|
|
@ -1383,6 +1392,8 @@ export type TextContent = Infer<typeof TextContentSchema>;
|
|||
export type ImageContent = Infer<typeof ImageContentSchema>;
|
||||
export type AudioContent = Infer<typeof AudioContentSchema>;
|
||||
export type EmbeddedResource = Infer<typeof EmbeddedResourceSchema>;
|
||||
export type ResourceLink = Infer<typeof ResourceLinkSchema>;
|
||||
export type ContentBlock = Infer<typeof ContentBlockSchema>;
|
||||
export type PromptMessage = Infer<typeof PromptMessageSchema>;
|
||||
export type GetPromptResult = Infer<typeof GetPromptResultSchema>;
|
||||
export type PromptListChangedNotification = Infer<typeof PromptListChangedNotificationSchema>;
|
||||
|
|
|
|||
Loading…
Reference in a new issue