diff --git a/README.md b/README.md index 03a7a12..2c94e40 100644 --- a/README.md +++ b/README.md @@ -9,14 +9,15 @@ This SDK provides high-level and dynamic access to those tools, making it easy t ## Features - Discover and list available `mcpd` hosted MCP servers -- Retrieve tool definitions and schemas for one or all servers +- Retrieve tool, prompt, and resource definitions from individual servers - Dynamically invoke any tool using a clean, attribute-based syntax -- Unified AI framework integration - works directly with LangChain JS and Vercel AI SDK +- Unified AI framework integration - works directly with LangChain JS and Vercel AI SDK via `getAgentTools()` - Generate self-contained, framework-compatible tool functions without conversion layers - Multiple output formats (`'array'`, `'object'`, `'map'`) for different framework needs - Full TypeScript support with comprehensive type definitions and overloads - Minimal dependencies (`lru-cache` for caching, `zod` for schema validation) - Works in both Node.js and browser environments +- Clean API wrapper over mcpd HTTP endpoints - no opinionated aggregation logic ## Installation @@ -47,7 +48,7 @@ console.log(servers); // Example: ['time', 'fetch', 'git'] // List tool definitions for a specific server -const tools = await client.servers.time.listTools(); +const tools = await client.servers.time.getTools(); console.log(tools); // Dynamically call a tool via the .tools namespace @@ -79,7 +80,7 @@ const client = new McpdClient({ const servers: string[] = await client.listServers(); // Get tools with proper typing -const tools: Tool[] = await client.servers.time.listTools(); +const tools: Tool[] = await client.servers.time.getTools(); // Dynamic tool invocation with error handling via .tools namespace try { @@ -122,38 +123,13 @@ const servers = await client.listServers(); // Returns: ['time', 'fetch', 'git'] ``` -#### `client.getToolSchemas(options?)` - -Returns tool schemas from all (or specific) servers with names transformed to `serverName__toolName` format. - -**IMPORTANT**: Tool names are automatically transformed to prevent naming clashes and identify server origin. Original tool name `get_current_time` on server `time` becomes `time__get_current_time`. - -This is useful for: - -- MCP servers aggregating and re-exposing tools from multiple upstream servers -- Tool inspection and discovery across all servers -- Custom tooling that needs raw MCP tool schemas with unique names - -```typescript -// Get all tools from all servers -const allTools = await client.getToolSchemas(); -// Returns: [ -// { name: "time__get_current_time", description: "...", inputSchema: {...} }, -// { name: "fetch__fetch_url", description: "...", inputSchema: {...} }, -// { name: "git__commit", description: "...", inputSchema: {...} } -// ] - -// Get tools from specific servers only -const someTools = await client.getToolSchemas({ servers: ["time", "fetch"] }); -``` - -#### `client.servers..listTools()` +#### `client.servers..getTools()` Returns tool schemas for a specific server. ```typescript // Get tools for a specific server -const timeTools = await client.servers.time.listTools(); +const timeTools = await client.servers.time.getTools(); // Returns: [{ name: 'get_current_time', description: '...', inputSchema: {...} }] ``` @@ -172,13 +148,13 @@ const result = await client.servers.weather.tools.get_forecast({ const time = await client.servers.time.tools.get_current_time(); ``` -#### `client.servers..listTools()` +#### `client.servers..getTools()` -List all tools available on a specific server. +Get all tools available on a specific server. ```typescript // List tools for a server using property access -const tools = await client.servers.time.listTools(); +const tools = await client.servers.time.getTools(); for (const tool of tools) { console.log(`${tool.name}: ${tool.description}`); } @@ -186,7 +162,7 @@ for (const tool of tools) { // Useful in loops with dynamic server names const servers = await client.listServers(); for (const serverName of servers) { - const tools = await client.servers[serverName].listTools(); + const tools = await client.servers[serverName].getTools(); console.log(`${serverName}: ${tools.length} tools`); } ``` @@ -228,30 +204,6 @@ if (await client.servers[serverName].hasTool("get_current_time")) { } ``` -#### `client.getPrompts(options?)` - -Returns prompt schemas from all (or specific) servers with names transformed to `serverName__promptName` format. - -**IMPORTANT**: Prompt names are automatically transformed to prevent naming clashes and identify server origin. Original prompt name `create_pr` on server `github` becomes `github__create_pr`. - -This is useful for: - -- Aggregating prompts from multiple servers -- Prompt inspection and discovery across all servers -- Custom tooling that needs raw MCP prompt schemas with unique names - -```typescript -// Get all prompts from all servers -const allPrompts = await client.getPrompts(); -// Returns: [ -// { name: "github__create_pr", description: "...", arguments: [...] }, -// { name: "slack__post_message", description: "...", arguments: [...] } -// ] - -// Get prompts from specific servers only -const somePrompts = await client.getPrompts({ servers: ["github"] }); -``` - #### `client.generatePrompt(namespacedName, args?)` Generate a prompt by its namespaced name (in `serverName__promptName` format). @@ -330,6 +282,65 @@ if (await client.servers[serverName].hasPrompt("create_pr")) { } ``` +#### `client.servers..getResources()` + +Returns resource schemas for a specific server. + +```typescript +// Get resources for a specific server +const githubResources = await client.servers.github.getResources(); +// Returns: [{ name: 'readme', uri: 'file:///repo/README.md', ... }] +``` + +#### `client.servers..getResourceTemplates()` + +Returns resource template schemas for a specific server. + +```typescript +// Get resource templates for a specific server +const githubTemplates = await client.servers.github.getResourceTemplates(); +// Returns: [{ name: 'file', uriTemplate: 'file:///{path}', ... }] +``` + +#### `client.servers..readResource(uri)` + +Read resource content by URI from a specific server. + +```typescript +// Read resource content by URI +const contents = await client.servers.github.readResource( + "file:///repo/README.md", +); +for (const content of contents) { + if (content.text) { + console.log(content.text); + } else if (content.blob) { + console.log("Binary content (base64):", content.blob); + } +} +``` + +#### `client.servers..hasResource(uri)` + +Check if a specific resource exists on a server. Resource URIs must match exactly as returned by the MCP server. + +```typescript +// Check if resource exists before reading it +if (await client.servers.github.hasResource("file:///repo/README.md")) { + const contents = await client.servers.github.readResource( + "file:///repo/README.md", + ); +} + +// Using with dynamic server names +const serverName = "github"; +if (await client.servers[serverName].hasResource("file:///repo/README.md")) { + const contents = await client.servers[serverName].readResource( + "file:///repo/README.md", + ); +} +``` + #### `client.getServerHealth(serverName?: string)` Get health information for one or all servers. diff --git a/examples/basic/index.js b/examples/basic/index.js index abd7fb9..9d002a3 100644 --- a/examples/basic/index.js +++ b/examples/basic/index.js @@ -35,7 +35,7 @@ async function main() { if (servers.includes('time')) { // Get tools for the time server console.log('Time server tools:'); - const tools = await client.servers.time.listTools(); + const tools = await client.servers.time.getTools(); for (const tool of tools) { console.log(` - ${tool.name}: ${tool.description || 'No description'}`); } diff --git a/src/apiPaths.ts b/src/apiPaths.ts index a49eef0..e01d057 100644 --- a/src/apiPaths.ts +++ b/src/apiPaths.ts @@ -25,6 +25,18 @@ export const API_PATHS = { PROMPT_GET_GENERATED: (serverName: string, promptName: string) => `${SERVERS_BASE}/${encodeURIComponent(serverName)}/prompts/${encodeURIComponent(promptName)}`, + // Resources + SERVER_RESOURCES: (serverName: string, cursor?: string) => { + const base = `${SERVERS_BASE}/${encodeURIComponent(serverName)}/resources`; + return cursor ? `${base}?cursor=${encodeURIComponent(cursor)}` : base; + }, + SERVER_RESOURCE_TEMPLATES: (serverName: string, cursor?: string) => { + const base = `${SERVERS_BASE}/${encodeURIComponent(serverName)}/resources/templates`; + return cursor ? `${base}?cursor=${encodeURIComponent(cursor)}` : base; + }, + RESOURCE_CONTENT: (serverName: string, uri: string) => + `${SERVERS_BASE}/${encodeURIComponent(serverName)}/resources/content?uri=${encodeURIComponent(uri)}`, + // Health HEALTH_ALL: HEALTH_SERVERS_BASE, HEALTH_SERVER: (serverName: string) => diff --git a/src/client.ts b/src/client.ts index 998ff81..5cba8a2 100644 --- a/src/client.ts +++ b/src/client.ts @@ -33,6 +33,11 @@ import { Prompts, GeneratePromptResponseBody, PromptGenerateArguments, + Resource, + Resources, + ResourceTemplate, + ResourceTemplates, + ResourceContent, } from "./types"; import { createCache } from "./utils/cache"; import { ServersNamespace } from "./dynamicCaller"; @@ -114,6 +119,9 @@ export class McpdClient { getTools: this.#getToolsByServer.bind(this), generatePrompt: this.#generatePromptInternal.bind(this), getPrompts: this.#getPromptsByServer.bind(this), + getResources: this.#getResourcesByServer.bind(this), + getResourceTemplates: this.#getResourceTemplatesByServer.bind(this), + readResource: this.#readResourceByServer.bind(this), }); this.#functionBuilder = new FunctionBuilder(this.#performCall.bind(this)); } @@ -254,154 +262,6 @@ export class McpdClient { return await this.#request(API_PATHS.SERVERS); } - /** - * Get tool schemas from all (or specific) MCP servers with transformed names. - * - * IMPORTANT: Tool names are transformed to `serverName__toolName` format to: - * 1. Prevent naming clashes when aggregating tools from multiple servers - * 2. Identify which server each tool belongs to - * - * This method automatically filters out unhealthy servers by checking their health - * status before fetching tools. Unhealthy servers are silently skipped to ensure - * the method returns quickly without waiting for timeouts on failed servers. - * - * Tool fetches from multiple servers are executed concurrently for optimal performance. - * - * This is useful for: - * - MCP servers that aggregate and re-expose tools from multiple upstream servers - * - Tool inspection and discovery across all servers - * - Custom tooling that needs raw MCP tool schemas - * - * @param options - Optional configuration - * @param options.servers - Array of server names to include. If not specified, includes all servers. - * @returns Array of tool schemas with transformed names (serverName__toolName). Only includes tools from healthy servers. - * @throws {ConnectionError} If unable to connect to the mcpd daemon - * @throws {TimeoutError} If requests to the daemon time out - * @throws {AuthenticationError} If API key authentication fails - * @throws {McpdError} If health check or initial server listing fails - * - * @example - * ```typescript - * // Get all tools from all servers - * const allTools = await client.getToolSchemas(); - * // Returns: [ - * // { name: "time__get_current_time", description: "...", ... }, - * // { name: "fetch__fetch_url", description: "...", ... } - * // ] - * - * // Get tools from specific servers only - * const someTools = await client.getToolSchemas({ servers: ['time', 'fetch'] }); - * - * // Original tool name "get_current_time" becomes "time__get_current_time" - * // This prevents clashes if multiple servers have tools with the same name - * ``` - */ - async getToolSchemas(options?: { servers?: string[] }): Promise { - const { servers } = options || {}; - - // Get healthy servers (fetches list if not provided, then filters by health). - const healthyServers = await this.#getHealthyServers(servers); - - // Fetch tools from all healthy servers in parallel - const results = await Promise.allSettled( - healthyServers.map(async (serverName) => ({ - serverName, - tools: await this.#getToolsByServer(serverName), - })), - ); - - // Process results and transform tool names - const allTools: Tool[] = []; - for (const result of results) { - if (result.status === "fulfilled") { - const { serverName, tools } = result.value; - // Transform tool names to serverName__toolName format - for (const tool of tools) { - allTools.push({ - ...tool, - name: `${serverName}__${tool.name}`, - }); - } - } else { - // If we can't get tools for a server, skip it with a warning - console.warn(`Failed to get tools for server:`, result.reason); - } - } - - return allTools; - } - - /** - * Get prompts from all (or specific) MCP servers with namespaced names. - * - * IMPORTANT: Prompt names are transformed to `serverName__promptName` format to: - * 1. Prevent naming clashes when aggregating prompts from multiple servers - * 2. Identify which server each prompt belongs to - * - * This method automatically filters out unhealthy servers by checking their health - * status before fetching prompts. Unhealthy servers are silently skipped to ensure - * the method returns quickly without waiting for timeouts on failed servers. - * - * Servers that don't implement prompts (return 501 Not Implemented) are silently - * skipped, allowing this method to work with mixed server types. - * - * Prompt fetches from multiple servers are executed concurrently for optimal performance. - * - * @param options - Optional configuration - * @param options.servers - Array of server names to include. If not specified, includes all servers. - * @returns Array of prompt schemas with transformed names (serverName__promptName). Only includes prompts from healthy servers that support them. - * @throws {ConnectionError} If unable to connect to the mcpd daemon - * @throws {TimeoutError} If requests to the daemon time out - * @throws {AuthenticationError} If API key authentication fails - * @throws {McpdError} If health check or initial server listing fails - * - * @example - * ```typescript - * // Get all prompts from all servers - * const allPrompts = await client.getPrompts(); - * // Returns: [ - * // { name: "github__create_pr", description: "...", arguments: [...] }, - * // { name: "notion__create_page", description: "...", arguments: [...] } - * // ] - * - * // Get prompts from specific servers only - * const somePrompts = await client.getPrompts({ servers: ['github', 'notion'] }); - * - * // Original prompt name "create_pr" becomes "github__create_pr" - * // This prevents clashes if multiple servers have prompts with the same name - * ``` - */ - async getPrompts(options?: { servers?: string[] }): Promise { - const { servers } = options || {}; - - // Get healthy servers (fetches list if not provided, then filters by health). - const healthyServers = await this.#getHealthyServers(servers); - - // Fetch prompts from all healthy servers in parallel. - const results = await Promise.allSettled( - healthyServers.map(async (serverName) => ({ - serverName, - prompts: await this.#getPromptsByServer(serverName), - })), - ); - - // Process results and transform prompt names. - const allPrompts: Prompt[] = results.flatMap((result) => { - if (result.status === "fulfilled") { - const { serverName, prompts } = result.value; - return prompts.map((prompt) => ({ - ...prompt, - name: `${serverName}__${prompt.name}`, - })); - } else { - console.warn(`Failed to get prompts for server:`, result.reason); - return []; // Return an empty array for rejected promises - } - }); - - return allPrompts; - } - /** * Generate a prompt from a template with the given arguments. * @@ -566,6 +426,110 @@ export class McpdClient { return response; } + /** + * Internal method to get resource schemas for a server. + * Used internally for getResources and by dependency injection for ServersNamespace. + * + * @param serverName - Server name to get resources for + * @param cursor - Optional cursor for pagination + * @returns Resource schemas for the specified server + * @throws {ServerNotFoundError} If the specified server doesn't exist + * @throws {ServerUnhealthyError} If the server is not healthy + * @throws {ConnectionError} If unable to connect to the mcpd daemon + * @throws {TimeoutError} If the request times out + * @throws {McpdError} If the request fails + * @internal + */ + async #getResourcesByServer( + serverName: string, + cursor?: string, + ): Promise { + try { + // Check server health first. + await this.#ensureServerHealthy(serverName); + + const path = API_PATHS.SERVER_RESOURCES(serverName, cursor); + const response = await this.#request(path); + return response.resources || []; + } catch (error) { + // Handle 501 Not Implemented - server doesn't support resources. + if ( + error instanceof McpdError && + error.message.includes("501") && + error.message.includes("Not Implemented") + ) { + return []; + } + + throw error; + } + } + + /** + * Internal method to get resource template schemas for a server. + * Used internally for getResourceTemplates and by dependency injection for ServersNamespace. + * + * @param serverName - Server name to get resource templates for + * @param cursor - Optional cursor for pagination + * @returns Resource template schemas for the specified server + * @throws {ServerNotFoundError} If the specified server doesn't exist + * @throws {ServerUnhealthyError} If the server is not healthy + * @throws {ConnectionError} If unable to connect to the mcpd daemon + * @throws {TimeoutError} If the request times out + * @throws {McpdError} If the request fails + * @internal + */ + async #getResourceTemplatesByServer( + serverName: string, + cursor?: string, + ): Promise { + try { + // Check server health first. + await this.#ensureServerHealthy(serverName); + + const path = API_PATHS.SERVER_RESOURCE_TEMPLATES(serverName, cursor); + const response = await this.#request(path); + return response.templates || []; + } catch (error) { + // Handle 501 Not Implemented - server doesn't support resource templates. + if ( + error instanceof McpdError && + error.message.includes("501") && + error.message.includes("Not Implemented") + ) { + return []; + } + + throw error; + } + } + + /** + * Internal method to read resource content from a server. + * Used by dependency injection for ServersNamespace. + * + * @param serverName - Server name to read resource from + * @param uri - The resource URI + * @returns Array of resource contents (text or blob) + * @throws {ServerNotFoundError} If the specified server doesn't exist + * @throws {ServerUnhealthyError} If the server is not healthy + * @throws {ConnectionError} If unable to connect to the mcpd daemon + * @throws {TimeoutError} If the request times out + * @throws {McpdError} If the request fails + * @internal + */ + async #readResourceByServer( + serverName: string, + uri: string, + ): Promise { + // Check server health first. + await this.#ensureServerHealthy(serverName); + + const path = API_PATHS.RESOURCE_CONTENT(serverName, uri); + const response = await this.#request(path); + return response || []; + } + /** * Get health information for one or all servers. * @@ -818,23 +782,19 @@ export class McpdClient { })), ); - // Build functions from tool schemas - const agentTools: AgentFunction[] = []; - for (const result of results) { - if (result.status === "fulfilled") { + // Build functions from tool schemas. + // Silently skip failed servers - they're already filtered by health checks. + const agentTools: AgentFunction[] = results + .filter((result) => result.status === "fulfilled") + .flatMap((result) => { const { serverName, tools } = result.value; - for (const toolSchema of tools) { - const func = this.#functionBuilder.createFunctionFromSchema( + return tools.map((toolSchema) => + this.#functionBuilder.createFunctionFromSchema( toolSchema, serverName, - ); - agentTools.push(func); - } - } else { - // If we can't get tools for a server, skip it with a warning - console.warn(`Failed to get tools for server:`, result.reason); - } - } + ), + ); + }); return agentTools; } diff --git a/src/dynamicCaller.ts b/src/dynamicCaller.ts index 75bd9dc..572a210 100644 --- a/src/dynamicCaller.ts +++ b/src/dynamicCaller.ts @@ -17,11 +17,17 @@ import { ToolNotFoundError } from "./errors"; import type { Tool, Prompt, + Resource, + ResourceTemplate, + ResourceContent, GeneratePromptResponseBody, PerformCallFn, GetToolsFn, GetPromptsFn, GeneratePromptFn, + GetResourcesFn, + GetResourceTemplatesFn, + ReadResourceFn, } from "./types"; /** @@ -50,6 +56,9 @@ export class ServersNamespace { #getTools: GetToolsFn; #generatePrompt: GeneratePromptFn; #getPrompts: GetPromptsFn; + #getResources: GetResourcesFn; + #getResourceTemplates: GetResourceTemplatesFn; + #readResource: ReadResourceFn; /** * Initialize the ServersNamespace with injected functions. @@ -59,22 +68,34 @@ export class ServersNamespace { * @param options.getTools - Function to get tool schemas * @param options.generatePrompt - Function to generate prompts * @param options.getPrompts - Function to get prompt schemas + * @param options.getResources - Function to get resources + * @param options.getResourceTemplates - Function to get resource templates + * @param options.readResource - Function to read resource content */ constructor({ performCall, getTools, generatePrompt, getPrompts, + getResources, + getResourceTemplates, + readResource, }: { performCall: PerformCallFn; getTools: GetToolsFn; generatePrompt: GeneratePromptFn; getPrompts: GetPromptsFn; + getResources: GetResourcesFn; + getResourceTemplates: GetResourceTemplatesFn; + readResource: ReadResourceFn; }) { this.#performCall = performCall; this.#getTools = getTools; this.#generatePrompt = generatePrompt; this.#getPrompts = getPrompts; + this.#getResources = getResources; + this.#getResourceTemplates = getResourceTemplates; + this.#readResource = readResource; // Return a Proxy to intercept property access return new Proxy(this, { @@ -87,6 +108,9 @@ export class ServersNamespace { target.#getTools, target.#generatePrompt, target.#getPrompts, + target.#getResources, + target.#getResourceTemplates, + target.#readResource, serverName, ); }, @@ -106,7 +130,7 @@ export class ServersNamespace { * const timeServer = client.servers.time; // Returns Server(...) * * // List available tools - * const tools = await timeServer.listTools(); + * const tools = await timeServer.getTools(); * * // Call tools through the .tools namespace: * await timeServer.tools.get_current_time({ timezone: "UTC" }) @@ -120,6 +144,9 @@ export class Server { #getTools: GetToolsFn; #generatePrompt: GeneratePromptFn; #getPrompts: GetPromptsFn; + #getResources: GetResourcesFn; + #getResourceTemplates: GetResourceTemplatesFn; + #readResource: ReadResourceFn; #serverName: string; /** @@ -129,6 +156,9 @@ export class Server { * @param getTools - Function to get tool schemas * @param generatePrompt - Function to generate prompts * @param getPrompts - Function to get prompt schemas + * @param getResources - Function to get resources + * @param getResourceTemplates - Function to get resource templates + * @param readResource - Function to read resource content * @param serverName - The name of the MCP server */ constructor( @@ -136,12 +166,18 @@ export class Server { getTools: GetToolsFn, generatePrompt: GeneratePromptFn, getPrompts: GetPromptsFn, + getResources: GetResourcesFn, + getResourceTemplates: GetResourceTemplatesFn, + readResource: ReadResourceFn, serverName: string, ) { this.#performCall = performCall; this.#getTools = getTools; this.#generatePrompt = generatePrompt; this.#getPrompts = getPrompts; + this.#getResources = getResources; + this.#getResourceTemplates = getResourceTemplates; + this.#readResource = readResource; this.#serverName = serverName; // Create the tools namespace as a real property. @@ -160,7 +196,7 @@ export class Server { } /** - * List all tools available on this server. + * Get all tools available on this server. * * @returns Array of tool schemas * @throws {ServerNotFoundError} If the server doesn't exist @@ -168,13 +204,13 @@ export class Server { * * @example * ```typescript - * const tools = await client.servers.time.listTools(); + * const tools = await client.servers.time.getTools(); * for (const tool of tools) { * console.log(`${tool.name}: ${tool.description}`); * } * ``` */ - async listTools(): Promise { + async getTools(): Promise { return this.#getTools(this.#serverName); } @@ -241,7 +277,7 @@ export class Server { if (!tool) { throw new ToolNotFoundError( `Tool '${toolName}' not found on server '${this.#serverName}'. ` + - `Use client.servers.${this.#serverName}.listTools() to see available tools.`, + `Use client.servers.${this.#serverName}.getTools() to see available tools.`, this.#serverName, toolName, ); @@ -346,6 +382,106 @@ export class Server { // Generate the prompt. return this.#generatePrompt(this.#serverName, promptName, args); } + + /** + * Get all resources available on this server. + * + * Note: This method is marked `async` for consistency with other server methods, + * even though it doesn't directly await. This maintains a uniform async interface + * and allows for future enhancements without breaking the API contract. + * + * @returns Array of resource schemas with original names + * @throws {ServerNotFoundError} If the server doesn't exist + * @throws {ServerUnhealthyError} If the server is unhealthy + * + * @example + * ```typescript + * const resources = await client.servers.github.getResources(); + * for (const resource of resources) { + * console.log(`${resource.name}: ${resource.uri}`); + * } + * ``` + */ + async getResources(): Promise { + return this.#getResources(this.#serverName); + } + + /** + * Get all resource templates available on this server. + * + * Note: This method is marked `async` for consistency with other server methods, + * even though it doesn't directly await. This maintains a uniform async interface + * and allows for future enhancements without breaking the API contract. + * + * @returns Array of resource template schemas with original names + * @throws {ServerNotFoundError} If the server doesn't exist + * @throws {ServerUnhealthyError} If the server is unhealthy + * + * @example + * ```typescript + * const templates = await client.servers.github.getResourceTemplates(); + * for (const template of templates) { + * console.log(`${template.name}: ${template.uriTemplate}`); + * } + * ``` + */ + async getResourceTemplates(): Promise { + return this.#getResourceTemplates(this.#serverName); + } + + /** + * Check if a resource exists on this server. + * + * The resource URI must match exactly as returned by the server. + * + * This method is designed as a safe boolean predicate - it catches all errors + * (ServerNotFoundError, ServerUnhealthyError, ConnectionError, etc.) and returns + * false rather than throwing. This makes it safe to use in conditional checks + * without requiring error handling. + * + * @param uri - The exact URI of the resource to check + * @returns True if the resource exists, false otherwise (including on errors) + * + * @example + * ```typescript + * if (await client.servers.github.hasResource('file:///repo/README.md')) { + * const content = await client.servers.github.readResource('file:///repo/README.md'); + * } + * ``` + */ + async hasResource(uri: string): Promise { + try { + const resources = await this.#getResources(this.#serverName); + return resources.some((r) => r.uri === uri); + } catch { + // Return false on any error to provide a safe boolean predicate. + return false; + } + } + + /** + * Read resource content by URI from this server. + * + * @param uri - The resource URI + * @returns Array of resource contents (text or blob) + * @throws {ServerNotFoundError} If the server doesn't exist + * @throws {ServerUnhealthyError} If the server is unhealthy + * + * @example + * ```typescript + * const contents = await client.servers.github.readResource('file:///repo/README.md'); + * for (const content of contents) { + * if (content.text) { + * console.log(content.text); + * } else if (content.blob) { + * console.log('Binary content:', content.blob.substring(0, 50) + '...'); + * } + * } + * ``` + */ + async readResource(uri: string): Promise { + return this.#readResource(this.#serverName, uri); + } } /** @@ -406,7 +542,7 @@ export class ToolsNamespace { if (!tool) { throw new ToolNotFoundError( `Tool '${toolName}' not found on server '${target.#serverName}'. ` + - `Use client.servers.${target.#serverName}.listTools() to see available tools.`, + `Use client.servers.${target.#serverName}.getTools() to see available tools.`, target.#serverName, toolName, ); diff --git a/src/types.ts b/src/types.ts index 76474f2..fc45b81 100644 --- a/src/types.ts +++ b/src/types.ts @@ -283,6 +283,32 @@ export type GeneratePromptFn = ( args?: Record, ) => Promise; +/** + * Function signature for getting resources from a server. + * This is injected into proxy classes via dependency injection. + * @internal + */ +export type GetResourcesFn = (serverName: string) => Promise; + +/** + * Function signature for getting resource templates from a server. + * This is injected into proxy classes via dependency injection. + * @internal + */ +export type GetResourceTemplatesFn = ( + serverName: string, +) => Promise; + +/** + * Function signature for reading resource content from a server. + * This is injected into proxy classes via dependency injection. + * @internal + */ +export type ReadResourceFn = ( + serverName: string, + uri: string, +) => Promise; + /** * MCP resource definition. */ diff --git a/tests/unit/apiSurface.test.ts b/tests/unit/apiSurface.test.ts index a656635..fafd140 100644 --- a/tests/unit/apiSurface.test.ts +++ b/tests/unit/apiSurface.test.ts @@ -72,7 +72,7 @@ describe("API Surface - Complete Test Coverage", () => { expect(isHealthy).toBe(true); }); - it("client.servers.foo.listTools()", async () => { + it("client.servers.foo.getTools()", async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ status: "ok" }), @@ -82,11 +82,11 @@ describe("API Surface - Complete Test Coverage", () => { json: async () => ({ tools: [{ name: "tool1" }] }), }); - const tools = await client.servers.time!.listTools(); + const tools = await client.servers.time!.getTools(); expect(tools).toHaveLength(1); }); - it('client.servers["foo"].listTools()', async () => { + it('client.servers["foo"].getTools()', async () => { const serverName = "time"; mockFetch.mockResolvedValueOnce({ ok: true, @@ -97,7 +97,7 @@ describe("API Surface - Complete Test Coverage", () => { json: async () => ({ tools: [{ name: "tool1" }] }), }); - const tools = await client.servers[serverName]!.listTools(); + const tools = await client.servers[serverName]!.getTools(); expect(tools).toHaveLength(1); }); @@ -243,46 +243,6 @@ describe("API Surface - Complete Test Coverage", () => { expect(result).toEqual({ result: "12:00" }); }); - it("client.getToolSchemas() - no options", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ["time"], - }); - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - servers: [{ name: "time", status: "ok" }], - }), - }); - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - tools: [{ name: "get_time", inputSchema: { type: "object" } }], - }), - }); - - const schemas = await client.getToolSchemas(); - expect(schemas[0]?.name).toBe("time__get_time"); - }); - - it("client.getToolSchemas(options) - with servers filter", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - servers: [{ name: "time", status: "ok" }], - }), - }); - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - tools: [{ name: "get_time", inputSchema: { type: "object" } }], - }), - }); - - const schemas = await client.getToolSchemas({ servers: ["time"] }); - expect(schemas[0]?.name).toBe("time__get_time"); - }); - it("client.getAgentTools()", async () => { mockFetch.mockResolvedValueOnce({ ok: true, diff --git a/tests/unit/client.test.ts b/tests/unit/client.test.ts index 284cebd..4636ac5 100644 --- a/tests/unit/client.test.ts +++ b/tests/unit/client.test.ts @@ -122,190 +122,6 @@ describe("McpdClient", () => { }); }); - describe("getToolSchemas()", () => { - it("should return all tools from all servers with transformed names", async () => { - const mockTools = { - time: [ - { - name: "get_current_time", - description: "Get current time", - inputSchema: { type: "object" }, - }, - { - name: "convert_timezone", - description: "Convert timezone", - inputSchema: { type: "object" }, - }, - ], - math: [ - { - name: "add", - description: "Add two numbers", - inputSchema: { type: "object" }, - }, - ], - }; - - // First call: listServers() - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ["time", "math"], - }); - - // Second call: health check for all servers (populates cache) - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - servers: [ - { - name: "time", - status: "ok", - latency: "2ms", - lastChecked: "2025-10-07T15:00:00Z", - lastSuccessful: "2025-10-07T15:00:00Z", - }, - { - name: "math", - status: "ok", - latency: "1ms", - lastChecked: "2025-10-07T15:00:00Z", - lastSuccessful: "2025-10-07T15:00:00Z", - }, - ], - }), - }); - - // Third+Fourth calls: tools for 'time' and 'math' (parallel, order may vary) - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ tools: mockTools.time }), - }); - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ tools: mockTools.math }), - }); - - const tools = await client.getToolSchemas(); - - expect(tools).toHaveLength(3); - expect(tools[0]?.name).toBe("time__get_current_time"); - expect(tools[1]?.name).toBe("time__convert_timezone"); - expect(tools[2]?.name).toBe("math__add"); - }); - - it("should filter tools by specified servers", async () => { - const mockTools = { - time: [ - { - name: "get_current_time", - description: "Get current time", - inputSchema: { type: "object" }, - }, - ], - }; - - // First call: health check for all servers (populates cache) - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - servers: [ - { - name: "time", - status: "ok", - latency: "2ms", - lastChecked: "2025-10-07T15:00:00Z", - lastSuccessful: "2025-10-07T15:00:00Z", - }, - ], - }), - }); - - // Second call: tools for 'time' - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ tools: mockTools.time }), - }); - - const tools = await client.getToolSchemas({ servers: ["time"] }); - - expect(tools).toHaveLength(1); - expect(tools[0]?.name).toBe("time__get_current_time"); - }); - - it("should return empty array when no tools available", async () => { - // First call: listServers() - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => [], - }); - - // Second call: health check for all servers (empty) - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - servers: [], - }), - }); - - const tools = await client.getToolSchemas(); - - expect(tools).toHaveLength(0); - }); - - it("should skip unhealthy servers and continue", async () => { - const mockTools = { - time: [ - { - name: "get_current_time", - description: "Get current time", - inputSchema: { type: "object" }, - }, - ], - }; - - // First call: listServers() - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ["time", "unhealthy"], - }); - - // Second call: health check for all servers (populates cache) - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - servers: [ - { - name: "time", - status: "ok", - latency: "2ms", - lastChecked: "2025-10-07T15:00:00Z", - lastSuccessful: "2025-10-07T15:00:00Z", - }, - { - name: "unhealthy", - status: "error", - latency: "0ms", - lastChecked: "2025-10-07T15:00:00Z", - }, - ], - }), - }); - - // Third call: tools for 'time' (unhealthy server is filtered out, no request made) - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ tools: mockTools.time }), - }); - - const tools = await client.getToolSchemas(); - - // Should only get tools from healthy server - expect(tools).toHaveLength(1); - expect(tools[0]?.name).toBe("time__get_current_time"); - }); - }); - describe("getServerHealth()", () => { it("should return health for all servers", async () => { const mockApiResponse = { @@ -559,256 +375,6 @@ describe("McpdClient", () => { }); }); - describe("getPrompts()", () => { - it("should return all prompts from all servers with transformed names", async () => { - const mockPrompts = { - github: [ - { - name: "create_pr", - description: "Create a pull request", - arguments: [{ name: "title", required: true }], - }, - { - name: "close_issue", - description: "Close an issue", - arguments: [{ name: "number", required: true }], - }, - ], - notion: [ - { - name: "create_page", - description: "Create a new page", - arguments: [{ name: "title", required: true }], - }, - ], - }; - - // First call: listServers() - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ["github", "notion"], - }); - - // Second call: health check for all servers - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - servers: [ - { - name: "github", - status: "ok", - latency: "2ms", - lastChecked: "2025-10-07T15:00:00Z", - lastSuccessful: "2025-10-07T15:00:00Z", - }, - { - name: "notion", - status: "ok", - latency: "1ms", - lastChecked: "2025-10-07T15:00:00Z", - lastSuccessful: "2025-10-07T15:00:00Z", - }, - ], - }), - }); - - // Third+Fourth calls: prompts for 'github' and 'notion' (parallel) - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ prompts: mockPrompts.github }), - }); - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ prompts: mockPrompts.notion }), - }); - - const prompts = await client.getPrompts(); - - expect(prompts).toHaveLength(3); - expect(prompts[0]?.name).toBe("github__create_pr"); - expect(prompts[1]?.name).toBe("github__close_issue"); - expect(prompts[2]?.name).toBe("notion__create_page"); - }); - - it("should filter prompts by specified servers", async () => { - const mockPrompts = { - github: [ - { - name: "create_pr", - description: "Create a pull request", - arguments: [], - }, - ], - }; - - // First call: health check for all servers - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - servers: [ - { - name: "github", - status: "ok", - latency: "2ms", - lastChecked: "2025-10-07T15:00:00Z", - lastSuccessful: "2025-10-07T15:00:00Z", - }, - ], - }), - }); - - // Second call: prompts for 'github' - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ prompts: mockPrompts.github }), - }); - - const prompts = await client.getPrompts({ servers: ["github"] }); - - expect(prompts).toHaveLength(1); - expect(prompts[0]?.name).toBe("github__create_pr"); - }); - - it("should return empty array when no prompts available", async () => { - // First call: listServers() - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => [], - }); - - // Second call: health check for all servers (empty) - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - servers: [], - }), - }); - - const prompts = await client.getPrompts(); - - expect(prompts).toHaveLength(0); - }); - - it("should skip servers that return 501 Not Implemented", async () => { - const mockPrompts = { - github: [ - { - name: "create_pr", - description: "Create a pull request", - arguments: [], - }, - ], - }; - - // First call: listServers() - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ["github", "no-prompts"], - }); - - // Second call: health check for all servers - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - servers: [ - { - name: "github", - status: "ok", - latency: "2ms", - lastChecked: "2025-10-07T15:00:00Z", - lastSuccessful: "2025-10-07T15:00:00Z", - }, - { - name: "no-prompts", - status: "ok", - latency: "1ms", - lastChecked: "2025-10-07T15:00:00Z", - lastSuccessful: "2025-10-07T15:00:00Z", - }, - ], - }), - }); - - // Third call: prompts for 'github' (success) - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ prompts: mockPrompts.github }), - }); - - // Fourth call: prompts for 'no-prompts' (501 error) - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 501, - statusText: "Not Implemented", - text: async () => - JSON.stringify({ - detail: "Server does not support prompts", - status: 501, - title: "Not Implemented", - type: "about:blank", - }), - }); - - const prompts = await client.getPrompts(); - - // Should only get prompts from server that supports them - expect(prompts).toHaveLength(1); - expect(prompts[0]?.name).toBe("github__create_pr"); - }); - - it("should skip unhealthy servers", async () => { - const mockPrompts = { - github: [ - { - name: "create_pr", - description: "Create a pull request", - arguments: [], - }, - ], - }; - - // First call: listServers() - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ["github", "unhealthy"], - }); - - // Second call: health check for all servers - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - servers: [ - { - name: "github", - status: "ok", - latency: "2ms", - lastChecked: "2025-10-07T15:00:00Z", - lastSuccessful: "2025-10-07T15:00:00Z", - }, - { - name: "unhealthy", - status: "error", - latency: "0ms", - lastChecked: "2025-10-07T15:00:00Z", - }, - ], - }), - }); - - // Third call: prompts for 'github' (unhealthy server is filtered out) - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ prompts: mockPrompts.github }), - }); - - const prompts = await client.getPrompts(); - - expect(prompts).toHaveLength(1); - expect(prompts[0]?.name).toBe("github__create_pr"); - }); - }); - describe("generatePrompt()", () => { it("should generate prompt with arguments", async () => { const mockResponse = { @@ -898,9 +464,7 @@ describe("McpdClient", () => { ); }); }); -}); -describe("HealthStatusHelpers", () => { describe("isHealthy()", () => { it("should return true for ok status", () => { expect(HealthStatusHelpers.isHealthy("ok")).toBe(true); diff --git a/tests/unit/dynamicCaller.test.ts b/tests/unit/dynamicCaller.test.ts index 9468583..d3c304d 100644 --- a/tests/unit/dynamicCaller.test.ts +++ b/tests/unit/dynamicCaller.test.ts @@ -7,7 +7,7 @@ import { ToolNotFoundError } from "../../src/errors"; * * These tests ensure that all documented API patterns work correctly: * - client.servers[serverName]! (dynamic server access) - * - client.servers.foo.listTools() (list tools) + * - client.servers.foo.getTools() (list tools) * - client.servers.foo!.tools.bar!(args) (static tool call) * - client.servers.foo.callTool(name, args) (dynamic tool call) * - client.servers.foo.hasTool(name) (check tool existence) @@ -61,14 +61,14 @@ describe("Dynamic Calling Patterns", () => { }), }); - const tools = await client.servers[serverName]!.listTools(); + const tools = await client.servers[serverName]!.getTools(); expect(tools).toHaveLength(1); expect(tools[0]?.name).toBe("get_current_time"); }); }); - describe("Pattern: client.servers.foo.listTools()", () => { + describe("Pattern: client.servers.foo.getTools()", () => { it("should list tools with static property access", async () => { // Health check. mockFetch.mockResolvedValueOnce({ @@ -92,7 +92,7 @@ describe("Dynamic Calling Patterns", () => { }), }); - const tools = await client.servers.time!.listTools(); + const tools = await client.servers.time!.getTools(); expect(tools).toHaveLength(2); expect(tools[0]?.name).toBe("tool1"); @@ -473,7 +473,7 @@ describe("Dynamic Calling Patterns", () => { }); // Mix dynamic server with static method. - const tools = await client.servers[serverName]!.listTools(); + const tools = await client.servers[serverName]!.getTools(); expect(tools).toHaveLength(1); expect(tools[0]?.name).toBe("tool1"); @@ -761,4 +761,242 @@ describe("Dynamic Calling Patterns", () => { }); }); }); + + describe("Resource Dynamic Calling Patterns", () => { + describe("Pattern: client.servers.foo.getResources()", () => { + it("should list resources with static property access", async () => { + // Health check. + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + status: "ok", + latency: "2ms", + lastChecked: "2025-10-07T15:00:00Z", + lastSuccessful: "2025-10-07T15:00:00Z", + }), + }); + + // Resources list. + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + resources: [ + { + name: "readme", + uri: "file:///repo/README.md", + description: "Project readme", + mimeType: "text/markdown", + }, + { + name: "changelog", + uri: "file:///repo/CHANGELOG.md", + mimeType: "text/markdown", + }, + ], + }), + }); + + const resources = await client.servers.github!.getResources(); + + expect(resources).toHaveLength(2); + expect(resources[0]?.name).toBe("readme"); + expect(resources[0]?.uri).toBe("file:///repo/README.md"); + expect(resources[1]?.name).toBe("changelog"); + }); + }); + + describe("Pattern: client.servers.foo.getResourceTemplates()", () => { + it("should list resource templates with static property access", async () => { + // Health check. + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + status: "ok", + latency: "2ms", + lastChecked: "2025-10-07T15:00:00Z", + lastSuccessful: "2025-10-07T15:00:00Z", + }), + }); + + // Resource templates list. + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + templates: [ + { + name: "file", + uriTemplate: "file:///{path}", + description: "Access any file", + mimeType: "text/plain", + }, + ], + }), + }); + + const templates = await client.servers.github!.getResourceTemplates(); + + expect(templates).toHaveLength(1); + expect(templates[0]?.name).toBe("file"); + expect(templates[0]?.uriTemplate).toBe("file:///{path}"); + }); + }); + + describe("Pattern: client.servers.foo.readResource(uri)", () => { + it("should read resource content by URI", async () => { + // Health check. + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + status: "ok", + latency: "2ms", + lastChecked: "2025-10-07T15:00:00Z", + lastSuccessful: "2025-10-07T15:00:00Z", + }), + }); + + // Resource content. + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => [ + { + uri: "file:///repo/README.md", + text: "# My Project\n\nThis is a readme.", + mimeType: "text/markdown", + }, + ], + }); + + const contents = await client.servers.github!.readResource( + "file:///repo/README.md", + ); + + expect(contents).toHaveLength(1); + expect(contents[0]?.uri).toBe("file:///repo/README.md"); + expect(contents[0]?.text).toBe("# My Project\n\nThis is a readme."); + expect(contents[0]?.mimeType).toBe("text/markdown"); + expect(mockFetch).toHaveBeenCalledWith( + "http://localhost:8090/api/v1/servers/github/resources/content?uri=file%3A%2F%2F%2Frepo%2FREADME.md", + expect.objectContaining({ + headers: expect.any(Object), + }), + ); + }); + + it("should handle blob content", async () => { + // Health check. + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + status: "ok", + latency: "2ms", + lastChecked: "2025-10-07T15:00:00Z", + lastSuccessful: "2025-10-07T15:00:00Z", + }), + }); + + // Resource content with blob. + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => [ + { + uri: "file:///repo/logo.png", + blob: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + mimeType: "image/png", + }, + ], + }); + + const contents = await client.servers.github!.readResource( + "file:///repo/logo.png", + ); + + expect(contents).toHaveLength(1); + expect(contents[0]?.blob).toBeDefined(); + expect(contents[0]?.mimeType).toBe("image/png"); + }); + }); + + describe("Pattern: client.servers.foo.hasResource(uri)", () => { + it("should return true when resource exists", async () => { + // Health check. + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + status: "ok", + latency: "2ms", + lastChecked: "2025-10-07T15:00:00Z", + lastSuccessful: "2025-10-07T15:00:00Z", + }), + }); + + // Resources list. + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + resources: [ + { + name: "readme", + uri: "file:///repo/README.md", + description: "Project readme", + }, + ], + }), + }); + + const exists = await client.servers.github!.hasResource( + "file:///repo/README.md", + ); + + expect(exists).toBe(true); + }); + + it("should return false when resource does not exist", async () => { + // Health check. + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + status: "ok", + latency: "2ms", + lastChecked: "2025-10-07T15:00:00Z", + lastSuccessful: "2025-10-07T15:00:00Z", + }), + }); + + // Resources list. + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + resources: [ + { + name: "readme", + uri: "file:///repo/README.md", + }, + ], + }), + }); + + const exists = await client.servers.github!.hasResource( + "file:///repo/nonexistent.md", + ); + + expect(exists).toBe(false); + }); + + it("should return false on error (safe predicate)", async () => { + // Health check returns error. + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: "Internal Server Error", + text: async () => JSON.stringify({ detail: "Server error" }), + }); + + const exists = await client.servers.github!.hasResource( + "file:///repo/README.md", + ); + + expect(exists).toBe(false); + }); + }); + }); });