From 7e59081b43ca156771470a195794cdff716f3859 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Sun, 15 Jun 2025 22:19:53 +0100 Subject: [PATCH 1/3] add resource link --- README.md | 32 +++ src/examples/client/simpleStreamableHttp.ts | 74 ++++++- src/examples/server/simpleStreamableHttp.ts | 95 ++++++++- src/types.test.ts | 211 +++++++++++++++++++- src/types.ts | 37 ++-- 5 files changed, 433 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index d13378b53..e7fa574cf 100644 --- a/README.md +++ b/README.md @@ -194,8 +194,40 @@ 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" as const, + uri: "file:///project/README.md", + name: "README.md", + mimeType: "text/markdown" + }, + { + type: "resource_link" as const, + uri: "file:///project/src/index.ts", + name: "index.ts", + mimeType: "text/typescript" + } + ] + }) +); ``` +#### 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: diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index 63d7d60a9..8b539d97f 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -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 - 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 '); + } else { + await readResource(args[1]); + } + break; + case 'help': printHelp(); break; @@ -345,13 +357,37 @@ async function callTool(name: string, args: Record): Promise { 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 ' to read their content.`); + } } catch (error) { console.log(`Error calling tool ${name}: ${error}`); } @@ -489,6 +525,42 @@ async function listResources(): Promise { } } +async function readResource(uri: string): Promise { + 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 { if (client && transport) { try { diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index b66be93d0..e5d35a823 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -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'; @@ -174,6 +174,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 => { + 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 => { + 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 => { + 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; }; diff --git a/src/types.test.ts b/src/types.test.ts index 0fbc003de..ca49867a8 100644 --- a/src/types.test.ts +++ b/src/types.test.ts @@ -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([]); + } + }); + }); }); diff --git a/src/types.ts b/src/types.ts index 9ece70d46..0d051eded 100644 --- a/src/types.ts +++ b/src/types.ts @@ -735,18 +735,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(); @@ -890,13 +905,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. @@ -1376,6 +1385,8 @@ export type TextContent = Infer; export type ImageContent = Infer; export type AudioContent = Infer; export type EmbeddedResource = Infer; +export type ResourceLink = Infer; +export type ContentBlock = Infer; export type PromptMessage = Infer; export type GetPromptResult = Infer; export type PromptListChangedNotification = Infer; From 97519d3e32472410b04d8e20a4102f778c5d82aa Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 16 Jun 2025 12:08:21 +0100 Subject: [PATCH 2/3] add description to resourse link --- README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 0cf2b85e1..ff6c482f5 100644 --- a/README.md +++ b/README.md @@ -206,16 +206,18 @@ server.registerTool( { type: "text", text: `Found files matching "${pattern}":` }, // ResourceLinks let tools return references without file content { - type: "resource_link" as const, + type: "resource_link", uri: "file:///project/README.md", name: "README.md", - mimeType: "text/markdown" + mimeType: "text/markdown", + description: 'A README file' }, { - type: "resource_link" as const, + type: "resource_link", uri: "file:///project/src/index.ts", name: "index.ts", - mimeType: "text/typescript" + mimeType: "text/typescript", + description: 'An index file' } ] }) From 1e5f0e16ba4801d7c5cfa8227b2becdff2ec6571 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 16 Jun 2025 12:08:59 +0100 Subject: [PATCH 3/3] whitespaces --- src/types.test.ts | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/types.test.ts b/src/types.test.ts index ca49867a8..d163f03d0 100644 --- a/src/types.test.ts +++ b/src/types.test.ts @@ -1,5 +1,5 @@ -import { - LATEST_PROTOCOL_VERSION, +import { + LATEST_PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS, ResourceLinkSchema, ContentBlockSchema, @@ -28,7 +28,7 @@ describe("Types", () => { uri: "file:///path/to/file.txt", name: "file.txt" }; - + const result = ResourceLinkSchema.safeParse(resourceLink); expect(result.success).toBe(true); if (result.success) { @@ -48,7 +48,7 @@ describe("Types", () => { mimeType: "text/plain", _meta: { custom: "metadata" } }; - + const result = ResourceLinkSchema.safeParse(resourceLink); expect(result.success).toBe(true); if (result.success) { @@ -65,7 +65,7 @@ describe("Types", () => { uri: "file:///path/to/file.txt", name: "file.txt" }; - + const result = ResourceLinkSchema.safeParse(invalidResourceLink); expect(result.success).toBe(false); }); @@ -76,7 +76,7 @@ describe("Types", () => { uri: "file:///path/to/file.txt" // missing name }; - + const result = ResourceLinkSchema.safeParse(invalidResourceLink); expect(result.success).toBe(false); }); @@ -88,7 +88,7 @@ describe("Types", () => { type: "text", text: "Hello, world!" }; - + const result = ContentBlockSchema.safeParse(textContent); expect(result.success).toBe(true); if (result.success) { @@ -102,7 +102,7 @@ describe("Types", () => { data: "aGVsbG8=", // base64 encoded "hello" mimeType: "image/png" }; - + const result = ContentBlockSchema.safeParse(imageContent); expect(result.success).toBe(true); if (result.success) { @@ -116,7 +116,7 @@ describe("Types", () => { data: "aGVsbG8=", // base64 encoded "hello" mimeType: "audio/mp3" }; - + const result = ContentBlockSchema.safeParse(audioContent); expect(result.success).toBe(true); if (result.success) { @@ -131,7 +131,7 @@ describe("Types", () => { name: "file.txt", mimeType: "text/plain" }; - + const result = ContentBlockSchema.safeParse(resourceLink); expect(result.success).toBe(true); if (result.success) { @@ -148,7 +148,7 @@ describe("Types", () => { text: "File contents" } }; - + const result = ContentBlockSchema.safeParse(embeddedResource); expect(result.success).toBe(true); if (result.success) { @@ -169,7 +169,7 @@ describe("Types", () => { mimeType: "text/x-rust" } }; - + const result = PromptMessageSchema.safeParse(promptMessage); expect(result.success).toBe(true); if (result.success) { @@ -202,7 +202,7 @@ describe("Types", () => { } ] }; - + const result = CallToolResultSchema.safeParse(toolResult); expect(result.success).toBe(true); if (result.success) { @@ -215,7 +215,7 @@ describe("Types", () => { test("should validate empty content array with default", () => { const toolResult = {}; - + const result = CallToolResultSchema.safeParse(toolResult); expect(result.success).toBe(true); if (result.success) {