From 21203636615f13340d2fbcacab9f69d0e51b0ccd Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Fri, 17 Oct 2025 16:26:34 +0100 Subject: [PATCH 1/3] Implement full resources and resource templates support Server-level methods: - getResources(), getResourceTemplates(), readResource(uri) - hasResource(uri) safe boolean predicate Client-level methods: - Aggregation across servers with namespacing (serverName__resourceName) - Auto-population of resource cache on first readResource() call - Filtering by specified servers - Graceful handling of 501 Not Implemented and unhealthy servers --- README.md | 136 ++++++ src/apiPaths.ts | 12 + src/client.ts | 335 +++++++++++++ src/dynamicCaller.ts | 136 ++++++ src/types.ts | 33 ++ tests/unit/client.test.ts | 812 +++++++++++++++++++++++++++++++ tests/unit/dynamicCaller.test.ts | 238 +++++++++ 7 files changed, 1702 insertions(+) diff --git a/README.md b/README.md index 03a7a12..5e8a9f2 100644 --- a/README.md +++ b/README.md @@ -330,6 +330,142 @@ if (await client.servers[serverName].hasPrompt("create_pr")) { } ``` +#### `client.getResources(options?)` + +Returns resource schemas from all (or specific) servers with names transformed to `serverName__resourceName` format. + +**IMPORTANT**: Resource names are automatically transformed to prevent naming clashes and identify server origin. Original resource name `readme` on server `github` becomes `github__readme`. + +This is useful for: + +- Aggregating resources from multiple servers +- Resource inspection and discovery across all servers +- Custom tooling that needs raw MCP resource schemas with unique names + +```typescript +// Get all resources from all servers +const allResources = await client.getResources(); +// Returns: [ +// { name: "github__readme", uri: "file:///repo/README.md", _serverName: "github", ... }, +// { name: "slack__channels", uri: "slack://channels", _serverName: "slack", ... } +// ] + +// Get resources from specific servers only +const someResources = await client.getResources({ servers: ["github"] }); +``` + +#### `client.getResourceTemplates(options?)` + +Returns resource template schemas from all (or specific) servers with names transformed to `serverName__templateName` format. + +**IMPORTANT**: Template names are automatically transformed to prevent naming clashes and identify server origin. Original template name `file` on server `github` becomes `github__file`. + +This is useful for: + +- Aggregating resource templates from multiple servers +- Template inspection and discovery across all servers +- Understanding what parameterized resources are available + +```typescript +// Get all resource templates from all servers +const allTemplates = await client.getResourceTemplates(); +// Returns: [ +// { name: "github__file", uriTemplate: "file:///{path}", _serverName: "github", ... } +// ] + +// Get templates from specific servers only +const someTemplates = await client.getResourceTemplates({ + servers: ["github"], +}); +``` + +#### `client.readResource(namespacedName)` + +Read resource content by its namespaced name (in `serverName__resourceName` format). + +The SDK automatically populates an internal cache on first use. For better performance when reading multiple resources, you can optionally pre-populate the cache by calling `getResources()` first. + +```typescript +// Read content by namespaced name - cache populated automatically if needed +const contents = await client.readResource("github__readme"); +// Returns: [{ uri: "...", text: "# README\n...", mimeType: "text/markdown" }] + +for (const content of contents) { + if (content.text) { + console.log("Text content:", content.text); + } else if (content.blob) { + console.log( + "Binary content (base64):", + content.blob.substring(0, 50) + "...", + ); + } +} + +// For better performance when reading multiple resources, pre-populate the cache +await client.getResources(); +const readme = await client.readResource("github__readme"); +const changelog = await client.readResource("github__changelog"); +``` + +#### `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/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..d15102c 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"; @@ -76,6 +81,7 @@ export class McpdClient { readonly #timeout: number; readonly #serverHealthCache: LRUCache; readonly #functionBuilder: FunctionBuilder; + readonly #resourceCache: Map; readonly #cacheableExceptions = new Set([ ServerNotFoundError, ServerUnhealthyError, @@ -108,12 +114,18 @@ export class McpdClient { ttl: healthCacheTtlMs, }); + // Initialize resource cache for mapping namespaced names to server/URI. + this.#resourceCache = new Map(); + // Initialize servers namespace and function builder with injected functions this.servers = new ServersNamespace({ performCall: this.#performCall.bind(this), 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)); } @@ -463,6 +475,225 @@ export class McpdClient { return response; } + /** + * Get resources from all (or specific) MCP servers with namespaced names. + * + * IMPORTANT: Resource names are transformed to `serverName__resourceName` format to: + * 1. Prevent naming clashes when aggregating resources from multiple servers + * 2. Identify which server each resource belongs to + * + * This method automatically filters out unhealthy servers by checking their health + * status before fetching resources. Unhealthy servers are silently skipped to ensure + * the method returns quickly without waiting for timeouts on failed servers. + * + * Servers that don't implement resources (return 501 Not Implemented) are silently + * skipped, allowing this method to work with mixed server types. + * + * Resource 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 resource schemas with transformed names (serverName__resourceName). Only includes resources 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 resources from all servers + * const allResources = await client.getResources(); + * // Returns: [ + * // { name: "github__readme", uri: "file:///repo/README.md", _serverName: "github", ... }, + * // { name: "slack__channels", uri: "slack://...", _serverName: "slack", ... } + * // ] + * + * // Get resources from specific servers only + * const someResources = await client.getResources({ servers: ['github'] }); + * + * // Original resource name "readme" becomes "github__readme" + * // This prevents clashes if multiple servers have resources with the same name + * ``` + */ + async getResources(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 resources from all healthy servers in parallel. + const results = await Promise.allSettled( + healthyServers.map(async (serverName) => ({ + serverName, + resources: await this.#getResourcesByServer(serverName), + })), + ); + + // Process results and transform resource names. + const allResources: Resource[] = results.flatMap((result) => { + if (result.status === "fulfilled") { + const { serverName, resources } = result.value; + return resources.map((resource) => { + const namespacedResource = { + ...resource, + name: `${serverName}__${resource.name}`, + _serverName: serverName, + _resourceName: resource.name, + _uri: resource.uri, + }; + + // Populate resource cache for readResource(). + this.#resourceCache.set(namespacedResource.name, { + serverName, + uri: resource.uri, + }); + + return namespacedResource; + }); + } else { + console.warn(`Failed to get resources for server:`, result.reason); + return []; + } + }); + + return allResources; + } + + /** + * Get resource templates from all (or specific) MCP servers with namespaced names. + * + * IMPORTANT: Template names are transformed to `serverName__templateName` format to: + * 1. Prevent naming clashes when aggregating templates from multiple servers + * 2. Identify which server each template belongs to + * + * This method automatically filters out unhealthy servers by checking their health + * status before fetching templates. Unhealthy servers are silently skipped to ensure + * the method returns quickly without waiting for timeouts on failed servers. + * + * Servers that don't implement resource templates (return 501 Not Implemented) are + * silently skipped, allowing this method to work with mixed server types. + * + * Template 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 resource template schemas with transformed names (serverName__templateName). Only includes templates 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 resource templates from all servers + * const allTemplates = await client.getResourceTemplates(); + * // Returns: [ + * // { name: "github__file", uriTemplate: "file:///{path}", _serverName: "github", ... } + * // ] + * + * // Get templates from specific servers only + * const someTemplates = await client.getResourceTemplates({ servers: ['github'] }); + * + * // Original template name "file" becomes "github__file" + * // This prevents clashes if multiple servers have templates with the same name + * ``` + */ + async getResourceTemplates(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 resource templates from all healthy servers in parallel. + const results = await Promise.allSettled( + healthyServers.map(async (serverName) => ({ + serverName, + templates: await this.#getResourceTemplatesByServer(serverName), + })), + ); + + // Process results and transform template names. + const allTemplates: ResourceTemplate[] = results.flatMap((result) => { + if (result.status === "fulfilled") { + const { serverName, templates } = result.value; + return templates.map((template) => ({ + ...template, + name: `${serverName}__${template.name}`, + _serverName: serverName, + _templateName: template.name, + })); + } else { + console.warn( + `Failed to get resource templates for server:`, + result.reason, + ); + return []; + } + }); + + return allTemplates; + } + + /** + * Read resource content using a namespaced resource name. + * + * This method uses an internal cache to map namespaced names to their server and URI. + * If the resource is not found in cache, it automatically calls getResources() to + * populate the cache and retries the lookup. + * + * @param namespacedName - The namespaced resource name (serverName__resourceName) + * @returns Array of resource contents (text or blob) + * @throws {Error} If the resource is not found after populating the cache + * @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 + * + * @example + * ```typescript + * // Read content by namespaced name - cache populated automatically if needed + * const contents = await client.readResource("github__readme"); + * for (const content of contents) { + * if (content.text) { + * console.log(content.text); + * } else if (content.blob) { + * console.log('Binary content (base64):', content.blob.substring(0, 50) + '...'); + * } + * } + * + * // You can also pre-populate the cache for efficiency if reading multiple resources + * await client.getResources(); + * const readme = await client.readResource("github__readme"); + * const changelog = await client.readResource("github__changelog"); + * ``` + */ + async readResource(namespacedName: string): Promise { + // Look up resource in cache. + let cached = this.#resourceCache.get(namespacedName); + + // If not in cache, populate it by calling getResources(). + if (!cached) { + await this.getResources(); + cached = this.#resourceCache.get(namespacedName); + + // If still not found after populating cache, resource doesn't exist. + if (!cached) { + throw new Error( + `Resource '${namespacedName}' not found. ` + + `Use getResources() to see available resources.`, + ); + } + } + + const { serverName, uri } = cached; + + // Read resource content from server. + return this.#readResourceByServer(serverName, uri); + } + /** * Internal method to get tool schemas for a server. * Used by dependency injection for ServersNamespace and internally for getAgentTools. @@ -566,6 +797,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. * diff --git a/src/dynamicCaller.ts b/src/dynamicCaller.ts index 75bd9dc..400b83f 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, ); }, @@ -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. @@ -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); + } } /** diff --git a/src/types.ts b/src/types.ts index 76474f2..d63b02a 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. */ @@ -292,6 +318,10 @@ export interface Resource { description?: string; mimeType?: string; _meta?: Record; + // SDK additions for client-level aggregation. + _serverName?: string; + _resourceName?: string; + _uri?: string; } /** @@ -368,6 +398,9 @@ export interface ResourceTemplate { description?: string; mimeType?: string; _meta?: Record; + // SDK additions for client-level aggregation. + _serverName?: string; + _templateName?: string; } /** diff --git a/tests/unit/client.test.ts b/tests/unit/client.test.ts index 284cebd..717c0b0 100644 --- a/tests/unit/client.test.ts +++ b/tests/unit/client.test.ts @@ -898,6 +898,818 @@ describe("McpdClient", () => { ); }); }); + + describe("getResources()", () => { + it("should return all resources from all servers with transformed names", async () => { + const mockResources = { + files: [ + { + uri: "file:///Users/test/doc.txt", + name: "doc.txt", + description: "A text document", + mimeType: "text/plain", + }, + { + uri: "file:///Users/test/image.png", + name: "image.png", + description: "An image file", + mimeType: "image/png", + }, + ], + web: [ + { + uri: "https://example.com/page", + name: "example_page", + description: "Example web page", + mimeType: "text/html", + }, + ], + }; + + // First call: listServers() + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ["files", "web"], + }); + + // Second call: health check for all servers + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + servers: [ + { + name: "files", + status: "ok", + latency: "2ms", + lastChecked: "2025-10-07T15:00:00Z", + lastSuccessful: "2025-10-07T15:00:00Z", + }, + { + name: "web", + status: "ok", + latency: "1ms", + lastChecked: "2025-10-07T15:00:00Z", + lastSuccessful: "2025-10-07T15:00:00Z", + }, + ], + }), + }); + + // Third+Fourth calls: resources for 'files' and 'web' (parallel) + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ resources: mockResources.files }), + }); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ resources: mockResources.web }), + }); + + const resources = await client.getResources(); + + expect(resources).toHaveLength(3); + expect(resources[0]?.name).toBe("files__doc.txt"); + expect(resources[0]?._serverName).toBe("files"); + expect(resources[0]?._resourceName).toBe("doc.txt"); + expect(resources[0]?._uri).toBe("file:///Users/test/doc.txt"); + expect(resources[1]?.name).toBe("files__image.png"); + expect(resources[2]?.name).toBe("web__example_page"); + }); + + it("should filter resources by specified servers", async () => { + const mockResources = { + files: [ + { + uri: "file:///Users/test/doc.txt", + name: "doc.txt", + description: "A text document", + mimeType: "text/plain", + }, + ], + }; + + // First call: health check for all servers + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + servers: [ + { + name: "files", + status: "ok", + latency: "2ms", + lastChecked: "2025-10-07T15:00:00Z", + lastSuccessful: "2025-10-07T15:00:00Z", + }, + ], + }), + }); + + // Second call: resources for 'files' + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ resources: mockResources.files }), + }); + + const resources = await client.getResources({ servers: ["files"] }); + + expect(resources).toHaveLength(1); + expect(resources[0]?.name).toBe("files__doc.txt"); + }); + + it("should return empty array when no resources 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 resources = await client.getResources(); + + expect(resources).toHaveLength(0); + }); + + it("should skip servers that return 501 Not Implemented", async () => { + const mockResources = { + files: [ + { + uri: "file:///Users/test/doc.txt", + name: "doc.txt", + description: "A text document", + mimeType: "text/plain", + }, + ], + }; + + // First call: listServers() + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ["files", "no-resources"], + }); + + // Second call: health check for all servers + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + servers: [ + { + name: "files", + status: "ok", + latency: "2ms", + lastChecked: "2025-10-07T15:00:00Z", + lastSuccessful: "2025-10-07T15:00:00Z", + }, + { + name: "no-resources", + status: "ok", + latency: "1ms", + lastChecked: "2025-10-07T15:00:00Z", + lastSuccessful: "2025-10-07T15:00:00Z", + }, + ], + }), + }); + + // Third call: resources for 'files' (success) + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ resources: mockResources.files }), + }); + + // Fourth call: resources for 'no-resources' (501 error) + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 501, + statusText: "Not Implemented", + text: async () => + JSON.stringify({ + detail: "Server does not support resources", + status: 501, + title: "Not Implemented", + type: "about:blank", + }), + }); + + const resources = await client.getResources(); + + // Should only get resources from server that supports them + expect(resources).toHaveLength(1); + expect(resources[0]?.name).toBe("files__doc.txt"); + }); + + it("should skip unhealthy servers", async () => { + const mockResources = { + files: [ + { + uri: "file:///Users/test/doc.txt", + name: "doc.txt", + description: "A text document", + mimeType: "text/plain", + }, + ], + }; + + // First call: listServers() + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ["files", "unhealthy"], + }); + + // Second call: health check for all servers + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + servers: [ + { + name: "files", + 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: resources for 'files' (unhealthy server is filtered out) + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ resources: mockResources.files }), + }); + + const resources = await client.getResources(); + + expect(resources).toHaveLength(1); + expect(resources[0]?.name).toBe("files__doc.txt"); + }); + }); + + describe("getResourceTemplates()", () => { + it("should return all resource templates from all servers with transformed names", async () => { + const mockTemplates = { + files: [ + { + uriTemplate: "file:///Users/{username}/docs/{filename}", + name: "user_doc", + description: "User document template", + mimeType: "text/plain", + }, + ], + web: [ + { + uriTemplate: "https://example.com/{path}", + name: "web_page", + description: "Web page template", + mimeType: "text/html", + }, + ], + }; + + // First call: listServers() + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ["files", "web"], + }); + + // Second call: health check for all servers + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + servers: [ + { + name: "files", + status: "ok", + latency: "2ms", + lastChecked: "2025-10-07T15:00:00Z", + lastSuccessful: "2025-10-07T15:00:00Z", + }, + { + name: "web", + status: "ok", + latency: "1ms", + lastChecked: "2025-10-07T15:00:00Z", + lastSuccessful: "2025-10-07T15:00:00Z", + }, + ], + }), + }); + + // Third+Fourth calls: templates for 'files' and 'web' (parallel) + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ templates: mockTemplates.files }), + }); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ templates: mockTemplates.web }), + }); + + const templates = await client.getResourceTemplates(); + + expect(templates).toHaveLength(2); + expect(templates[0]?.name).toBe("files__user_doc"); + expect(templates[0]?._serverName).toBe("files"); + expect(templates[0]?._templateName).toBe("user_doc"); + expect(templates[1]?.name).toBe("web__web_page"); + }); + + it("should filter resource templates by specified servers", async () => { + const mockTemplates = { + files: [ + { + uriTemplate: "file:///Users/{username}/docs/{filename}", + name: "user_doc", + description: "User document template", + mimeType: "text/plain", + }, + ], + }; + + // First call: health check for all servers + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + servers: [ + { + name: "files", + status: "ok", + latency: "2ms", + lastChecked: "2025-10-07T15:00:00Z", + lastSuccessful: "2025-10-07T15:00:00Z", + }, + ], + }), + }); + + // Second call: templates for 'files' + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ templates: mockTemplates.files }), + }); + + const templates = await client.getResourceTemplates({ + servers: ["files"], + }); + + expect(templates).toHaveLength(1); + expect(templates[0]?.name).toBe("files__user_doc"); + }); + + it("should skip servers that return 501 Not Implemented", async () => { + const mockTemplates = { + files: [ + { + uriTemplate: "file:///Users/{username}/docs/{filename}", + name: "user_doc", + description: "User document template", + mimeType: "text/plain", + }, + ], + }; + + // First call: listServers() + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ["files", "no-templates"], + }); + + // Second call: health check for all servers + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + servers: [ + { + name: "files", + status: "ok", + latency: "2ms", + lastChecked: "2025-10-07T15:00:00Z", + lastSuccessful: "2025-10-07T15:00:00Z", + }, + { + name: "no-templates", + status: "ok", + latency: "1ms", + lastChecked: "2025-10-07T15:00:00Z", + lastSuccessful: "2025-10-07T15:00:00Z", + }, + ], + }), + }); + + // Third call: templates for 'files' (success) + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ templates: mockTemplates.files }), + }); + + // Fourth call: templates for 'no-templates' (501 error) + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 501, + statusText: "Not Implemented", + text: async () => + JSON.stringify({ + detail: "Server does not support resource templates", + status: 501, + title: "Not Implemented", + type: "about:blank", + }), + }); + + const templates = await client.getResourceTemplates(); + + // Should only get templates from server that supports them + expect(templates).toHaveLength(1); + expect(templates[0]?.name).toBe("files__user_doc"); + }); + + it("should skip unhealthy servers", async () => { + const mockTemplates = { + files: [ + { + uriTemplate: "file:///Users/{username}/docs/{filename}", + name: "user_doc", + description: "User document template", + mimeType: "text/plain", + }, + ], + }; + + // First call: listServers() + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ["files", "unhealthy"], + }); + + // Second call: health check for all servers + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + servers: [ + { + name: "files", + 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: templates for 'files' (unhealthy server is filtered out) + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ templates: mockTemplates.files }), + }); + + const templates = await client.getResourceTemplates(); + + expect(templates).toHaveLength(1); + expect(templates[0]?.name).toBe("files__user_doc"); + }); + }); + + describe("readResource()", () => { + it("should auto-populate cache and read resource content", async () => { + const mockResources = [ + { + uri: "file:///Users/test/doc.txt", + name: "doc.txt", + description: "A text document", + mimeType: "text/plain", + }, + ]; + + const mockContent = [ + { + uri: "file:///Users/test/doc.txt", + text: "Hello, world!", + mimeType: "text/plain", + }, + ]; + + // First call: listServers() (triggered by auto-populate) + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ["files"], + }); + + // Second call: health check (triggered by auto-populate) + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + servers: [ + { + name: "files", + status: "ok", + latency: "2ms", + lastChecked: "2025-10-07T15:00:00Z", + lastSuccessful: "2025-10-07T15:00:00Z", + }, + ], + }), + }); + + // Third call: getResources (auto-populate cache) + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ resources: mockResources }), + }); + + // Fourth call: read resource content (health check is cached from step 2) + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockContent, + }); + + const content = await client.readResource("files__doc.txt"); + + expect(content).toHaveLength(1); + expect(content[0]?.text).toBe("Hello, world!"); + expect(content[0]?.mimeType).toBe("text/plain"); + expect(mockFetch).toHaveBeenLastCalledWith( + expect.stringContaining( + "/api/v1/servers/files/resources/content?uri=file%3A%2F%2F%2FUsers%2Ftest%2Fdoc.txt", + ), + expect.any(Object), + ); + }); + + it("should read from pre-populated cache", async () => { + const mockResources = [ + { + uri: "file:///Users/test/doc.txt", + name: "doc.txt", + description: "A text document", + mimeType: "text/plain", + }, + ]; + + const mockContent = [ + { + uri: "file:///Users/test/doc.txt", + text: "Hello, world!", + mimeType: "text/plain", + }, + ]; + + // First call: listServers() + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ["files"], + }); + + // Second call: health check + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + servers: [ + { + name: "files", + status: "ok", + latency: "2ms", + lastChecked: "2025-10-07T15:00:00Z", + lastSuccessful: "2025-10-07T15:00:00Z", + }, + ], + }), + }); + + // Third call: getResources (pre-populate cache) + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ resources: mockResources }), + }); + + // Pre-populate the cache + await client.getResources(); + + // Fourth call: read resource content (health check is cached from step 2) + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockContent, + }); + + const content = await client.readResource("files__doc.txt"); + + expect(content).toHaveLength(1); + expect(content[0]?.text).toBe("Hello, world!"); + // Should only have 4 fetch calls total (not 7 if it auto-populated again) + expect(mockFetch).toHaveBeenCalledTimes(4); + }); + + it("should handle blob content", async () => { + const mockResources = [ + { + uri: "file:///Users/test/image.png", + name: "image.png", + description: "An image file", + mimeType: "image/png", + }, + ]; + + const mockContent = [ + { + uri: "file:///Users/test/image.png", + blob: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + mimeType: "image/png", + }, + ]; + + // First call: listServers() + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ["files"], + }); + + // Second call: health check + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + servers: [ + { + name: "files", + status: "ok", + latency: "2ms", + lastChecked: "2025-10-07T15:00:00Z", + lastSuccessful: "2025-10-07T15:00:00Z", + }, + ], + }), + }); + + // Third call: getResources (pre-populate cache) + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ resources: mockResources }), + }); + + await client.getResources(); + + // Fourth call: read resource content (health check is cached from step 2) + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockContent, + }); + + const content = await client.readResource("files__image.png"); + + expect(content).toHaveLength(1); + expect(content[0]?.blob).toBeDefined(); + expect(content[0]?.mimeType).toBe("image/png"); + }); + + it("should throw error when resource not found after cache population", async () => { + const mockResources = [ + { + uri: "file:///Users/test/doc.txt", + name: "doc.txt", + description: "A text document", + mimeType: "text/plain", + }, + ]; + + // First call: listServers() + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ["files"], + }); + + // Second call: health check + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + servers: [ + { + name: "files", + status: "ok", + latency: "2ms", + lastChecked: "2025-10-07T15:00:00Z", + lastSuccessful: "2025-10-07T15:00:00Z", + }, + ], + }), + }); + + // Third call: getResources (auto-populate cache) + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ resources: mockResources }), + }); + + await expect( + client.readResource("files__nonexistent.txt"), + ).rejects.toThrow( + "Resource 'files__nonexistent.txt' not found. Use getResources() to see available resources.", + ); + }); + + it("should throw error for invalid resource name format", async () => { + // First call: listServers() (triggered by auto-populate) + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => [], + }); + + // Second call: health check (triggered by auto-populate) + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + servers: [], + }), + }); + + await expect(client.readResource("invalid")).rejects.toThrow( + "Resource 'invalid' not found. Use getResources() to see available resources.", + ); + }); + + it("should handle resource names with underscores", async () => { + const mockResources = [ + { + uri: "file:///Users/test/my_file.txt", + name: "my_file.txt", + description: "A file with underscores", + mimeType: "text/plain", + }, + ]; + + const mockContent = [ + { + uri: "file:///Users/test/my_file.txt", + text: "Content", + mimeType: "text/plain", + }, + ]; + + // First call: listServers() + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ["files"], + }); + + // Second call: health check + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + servers: [ + { + name: "files", + status: "ok", + latency: "2ms", + lastChecked: "2025-10-07T15:00:00Z", + lastSuccessful: "2025-10-07T15:00:00Z", + }, + ], + }), + }); + + // Third call: getResources + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ resources: mockResources }), + }); + + await client.getResources(); + + // Fourth call: read resource content (health check is cached from step 2) + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockContent, + }); + + await client.readResource("files__my_file.txt"); + + expect(mockFetch).toHaveBeenLastCalledWith( + expect.stringContaining( + "/api/v1/servers/files/resources/content?uri=file%3A%2F%2F%2FUsers%2Ftest%2Fmy_file.txt", + ), + expect.any(Object), + ); + }); + }); }); describe("HealthStatusHelpers", () => { diff --git a/tests/unit/dynamicCaller.test.ts b/tests/unit/dynamicCaller.test.ts index 9468583..924b6dd 100644 --- a/tests/unit/dynamicCaller.test.ts +++ b/tests/unit/dynamicCaller.test.ts @@ -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); + }); + }); + }); }); From cab250fb835060d86d114d24aae1ef16e4f0a13b Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Mon, 20 Oct 2025 09:43:44 +0100 Subject: [PATCH 2/3] Refactor SDK method names (#6) * (client) getToolSchemas -> getTools * (server) listTools -> getTools * Remove console messages --- README.md | 22 +++++++++++----------- examples/basic/index.js | 2 +- src/client.ts | 32 +++++++++++--------------------- src/dynamicCaller.ts | 12 ++++++------ tests/unit/apiSurface.test.ts | 16 ++++++++-------- tests/unit/client.test.ts | 10 +++++----- tests/unit/dynamicCaller.test.ts | 10 +++++----- 7 files changed, 47 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 5e8a9f2..6cb08f2 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,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 +79,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,7 +122,7 @@ const servers = await client.listServers(); // Returns: ['time', 'fetch', 'git'] ``` -#### `client.getToolSchemas(options?)` +#### `client.getTools(options?)` Returns tool schemas from all (or specific) servers with names transformed to `serverName__toolName` format. @@ -136,7 +136,7 @@ This is useful for: ```typescript // Get all tools from all servers -const allTools = await client.getToolSchemas(); +const allTools = await client.getTools(); // Returns: [ // { name: "time__get_current_time", description: "...", inputSchema: {...} }, // { name: "fetch__fetch_url", description: "...", inputSchema: {...} }, @@ -144,16 +144,16 @@ const allTools = await client.getToolSchemas(); // ] // Get tools from specific servers only -const someTools = await client.getToolSchemas({ servers: ["time", "fetch"] }); +const someTools = await client.getTools({ 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 +172,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 +186,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`); } ``` 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/client.ts b/src/client.ts index d15102c..b73af9a 100644 --- a/src/client.ts +++ b/src/client.ts @@ -295,20 +295,20 @@ export class McpdClient { * @example * ```typescript * // Get all tools from all servers - * const allTools = await client.getToolSchemas(); + * const allTools = await client.getTools(); * // 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'] }); + * const someTools = await client.getTools({ 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 { + async getTools(options?: { servers?: string[] }): Promise { const { servers } = options || {}; // Get healthy servers (fetches list if not provided, then filters by health). @@ -334,10 +334,8 @@ export class McpdClient { 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); } + // Silently skip failed servers - they're already filtered by health checks } return allTools; @@ -405,10 +403,9 @@ export class McpdClient { ...prompt, name: `${serverName}__${prompt.name}`, })); - } else { - console.warn(`Failed to get prompts for server:`, result.reason); - return []; // Return an empty array for rejected promises } + // Silently skip failed servers - they're already filtered by health checks + return []; }); return allPrompts; @@ -550,10 +547,9 @@ export class McpdClient { return namespacedResource; }); - } else { - console.warn(`Failed to get resources for server:`, result.reason); - return []; } + // Silently skip failed servers - they're already filtered by health checks + return []; }); return allResources; @@ -624,13 +620,9 @@ export class McpdClient { _serverName: serverName, _templateName: template.name, })); - } else { - console.warn( - `Failed to get resource templates for server:`, - result.reason, - ); - return []; } + // Silently skip failed servers - they're already filtered by health checks + return []; }); return allTemplates; @@ -1165,10 +1157,8 @@ export class McpdClient { ); 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); } + // Silently skip failed servers - they're already filtered by health checks } return agentTools; diff --git a/src/dynamicCaller.ts b/src/dynamicCaller.ts index 400b83f..572a210 100644 --- a/src/dynamicCaller.ts +++ b/src/dynamicCaller.ts @@ -130,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" }) @@ -196,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 @@ -204,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); } @@ -277,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, ); @@ -542,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/tests/unit/apiSurface.test.ts b/tests/unit/apiSurface.test.ts index a656635..4b47a5f 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,7 +243,7 @@ describe("API Surface - Complete Test Coverage", () => { expect(result).toEqual({ result: "12:00" }); }); - it("client.getToolSchemas() - no options", async () => { + it("client.getTools() - no options", async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ["time"], @@ -261,11 +261,11 @@ describe("API Surface - Complete Test Coverage", () => { }), }); - const schemas = await client.getToolSchemas(); + const schemas = await client.getTools(); expect(schemas[0]?.name).toBe("time__get_time"); }); - it("client.getToolSchemas(options) - with servers filter", async () => { + it("client.getTools(options) - with servers filter", async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ @@ -279,7 +279,7 @@ describe("API Surface - Complete Test Coverage", () => { }), }); - const schemas = await client.getToolSchemas({ servers: ["time"] }); + const schemas = await client.getTools({ servers: ["time"] }); expect(schemas[0]?.name).toBe("time__get_time"); }); diff --git a/tests/unit/client.test.ts b/tests/unit/client.test.ts index 717c0b0..799d4c8 100644 --- a/tests/unit/client.test.ts +++ b/tests/unit/client.test.ts @@ -122,7 +122,7 @@ describe("McpdClient", () => { }); }); - describe("getToolSchemas()", () => { + describe("getTools()", () => { it("should return all tools from all servers with transformed names", async () => { const mockTools = { time: [ @@ -186,7 +186,7 @@ describe("McpdClient", () => { json: async () => ({ tools: mockTools.math }), }); - const tools = await client.getToolSchemas(); + const tools = await client.getTools(); expect(tools).toHaveLength(3); expect(tools[0]?.name).toBe("time__get_current_time"); @@ -227,7 +227,7 @@ describe("McpdClient", () => { json: async () => ({ tools: mockTools.time }), }); - const tools = await client.getToolSchemas({ servers: ["time"] }); + const tools = await client.getTools({ servers: ["time"] }); expect(tools).toHaveLength(1); expect(tools[0]?.name).toBe("time__get_current_time"); @@ -248,7 +248,7 @@ describe("McpdClient", () => { }), }); - const tools = await client.getToolSchemas(); + const tools = await client.getTools(); expect(tools).toHaveLength(0); }); @@ -298,7 +298,7 @@ describe("McpdClient", () => { json: async () => ({ tools: mockTools.time }), }); - const tools = await client.getToolSchemas(); + const tools = await client.getTools(); // Should only get tools from healthy server expect(tools).toHaveLength(1); diff --git a/tests/unit/dynamicCaller.test.ts b/tests/unit/dynamicCaller.test.ts index 924b6dd..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"); From 21636b10d45755cf3660869735c46db042f1f715 Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Mon, 20 Oct 2025 11:29:17 +0100 Subject: [PATCH 3/3] Remove over complex logic from SDK --- README.md | 131 +--- src/client.ts | 385 +--------- src/types.ts | 7 - tests/unit/apiSurface.test.ts | 40 - tests/unit/client.test.ts | 1358 ++------------------------------- 5 files changed, 68 insertions(+), 1853 deletions(-) diff --git a/README.md b/README.md index 6cb08f2..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 @@ -122,31 +123,6 @@ const servers = await client.listServers(); // Returns: ['time', 'fetch', 'git'] ``` -#### `client.getTools(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.getTools(); -// 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.getTools({ servers: ["time", "fetch"] }); -``` - #### `client.servers..getTools()` Returns tool schemas for a specific server. @@ -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,83 +282,6 @@ if (await client.servers[serverName].hasPrompt("create_pr")) { } ``` -#### `client.getResources(options?)` - -Returns resource schemas from all (or specific) servers with names transformed to `serverName__resourceName` format. - -**IMPORTANT**: Resource names are automatically transformed to prevent naming clashes and identify server origin. Original resource name `readme` on server `github` becomes `github__readme`. - -This is useful for: - -- Aggregating resources from multiple servers -- Resource inspection and discovery across all servers -- Custom tooling that needs raw MCP resource schemas with unique names - -```typescript -// Get all resources from all servers -const allResources = await client.getResources(); -// Returns: [ -// { name: "github__readme", uri: "file:///repo/README.md", _serverName: "github", ... }, -// { name: "slack__channels", uri: "slack://channels", _serverName: "slack", ... } -// ] - -// Get resources from specific servers only -const someResources = await client.getResources({ servers: ["github"] }); -``` - -#### `client.getResourceTemplates(options?)` - -Returns resource template schemas from all (or specific) servers with names transformed to `serverName__templateName` format. - -**IMPORTANT**: Template names are automatically transformed to prevent naming clashes and identify server origin. Original template name `file` on server `github` becomes `github__file`. - -This is useful for: - -- Aggregating resource templates from multiple servers -- Template inspection and discovery across all servers -- Understanding what parameterized resources are available - -```typescript -// Get all resource templates from all servers -const allTemplates = await client.getResourceTemplates(); -// Returns: [ -// { name: "github__file", uriTemplate: "file:///{path}", _serverName: "github", ... } -// ] - -// Get templates from specific servers only -const someTemplates = await client.getResourceTemplates({ - servers: ["github"], -}); -``` - -#### `client.readResource(namespacedName)` - -Read resource content by its namespaced name (in `serverName__resourceName` format). - -The SDK automatically populates an internal cache on first use. For better performance when reading multiple resources, you can optionally pre-populate the cache by calling `getResources()` first. - -```typescript -// Read content by namespaced name - cache populated automatically if needed -const contents = await client.readResource("github__readme"); -// Returns: [{ uri: "...", text: "# README\n...", mimeType: "text/markdown" }] - -for (const content of contents) { - if (content.text) { - console.log("Text content:", content.text); - } else if (content.blob) { - console.log( - "Binary content (base64):", - content.blob.substring(0, 50) + "...", - ); - } -} - -// For better performance when reading multiple resources, pre-populate the cache -await client.getResources(); -const readme = await client.readResource("github__readme"); -const changelog = await client.readResource("github__changelog"); -``` - #### `client.servers..getResources()` Returns resource schemas for a specific server. diff --git a/src/client.ts b/src/client.ts index b73af9a..5cba8a2 100644 --- a/src/client.ts +++ b/src/client.ts @@ -81,7 +81,6 @@ export class McpdClient { readonly #timeout: number; readonly #serverHealthCache: LRUCache; readonly #functionBuilder: FunctionBuilder; - readonly #resourceCache: Map; readonly #cacheableExceptions = new Set([ ServerNotFoundError, ServerUnhealthyError, @@ -114,9 +113,6 @@ export class McpdClient { ttl: healthCacheTtlMs, }); - // Initialize resource cache for mapping namespaced names to server/URI. - this.#resourceCache = new Map(); - // Initialize servers namespace and function builder with injected functions this.servers = new ServersNamespace({ performCall: this.#performCall.bind(this), @@ -266,151 +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.getTools(); - * // Returns: [ - * // { name: "time__get_current_time", description: "...", ... }, - * // { name: "fetch__fetch_url", description: "...", ... } - * // ] - * - * // Get tools from specific servers only - * const someTools = await client.getTools({ 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 getTools(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}`, - }); - } - } - // Silently skip failed servers - they're already filtered by health checks - } - - 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}`, - })); - } - // Silently skip failed servers - they're already filtered by health checks - return []; - }); - - return allPrompts; - } - /** * Generate a prompt from a template with the given arguments. * @@ -472,220 +323,6 @@ export class McpdClient { return response; } - /** - * Get resources from all (or specific) MCP servers with namespaced names. - * - * IMPORTANT: Resource names are transformed to `serverName__resourceName` format to: - * 1. Prevent naming clashes when aggregating resources from multiple servers - * 2. Identify which server each resource belongs to - * - * This method automatically filters out unhealthy servers by checking their health - * status before fetching resources. Unhealthy servers are silently skipped to ensure - * the method returns quickly without waiting for timeouts on failed servers. - * - * Servers that don't implement resources (return 501 Not Implemented) are silently - * skipped, allowing this method to work with mixed server types. - * - * Resource 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 resource schemas with transformed names (serverName__resourceName). Only includes resources 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 resources from all servers - * const allResources = await client.getResources(); - * // Returns: [ - * // { name: "github__readme", uri: "file:///repo/README.md", _serverName: "github", ... }, - * // { name: "slack__channels", uri: "slack://...", _serverName: "slack", ... } - * // ] - * - * // Get resources from specific servers only - * const someResources = await client.getResources({ servers: ['github'] }); - * - * // Original resource name "readme" becomes "github__readme" - * // This prevents clashes if multiple servers have resources with the same name - * ``` - */ - async getResources(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 resources from all healthy servers in parallel. - const results = await Promise.allSettled( - healthyServers.map(async (serverName) => ({ - serverName, - resources: await this.#getResourcesByServer(serverName), - })), - ); - - // Process results and transform resource names. - const allResources: Resource[] = results.flatMap((result) => { - if (result.status === "fulfilled") { - const { serverName, resources } = result.value; - return resources.map((resource) => { - const namespacedResource = { - ...resource, - name: `${serverName}__${resource.name}`, - _serverName: serverName, - _resourceName: resource.name, - _uri: resource.uri, - }; - - // Populate resource cache for readResource(). - this.#resourceCache.set(namespacedResource.name, { - serverName, - uri: resource.uri, - }); - - return namespacedResource; - }); - } - // Silently skip failed servers - they're already filtered by health checks - return []; - }); - - return allResources; - } - - /** - * Get resource templates from all (or specific) MCP servers with namespaced names. - * - * IMPORTANT: Template names are transformed to `serverName__templateName` format to: - * 1. Prevent naming clashes when aggregating templates from multiple servers - * 2. Identify which server each template belongs to - * - * This method automatically filters out unhealthy servers by checking their health - * status before fetching templates. Unhealthy servers are silently skipped to ensure - * the method returns quickly without waiting for timeouts on failed servers. - * - * Servers that don't implement resource templates (return 501 Not Implemented) are - * silently skipped, allowing this method to work with mixed server types. - * - * Template 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 resource template schemas with transformed names (serverName__templateName). Only includes templates 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 resource templates from all servers - * const allTemplates = await client.getResourceTemplates(); - * // Returns: [ - * // { name: "github__file", uriTemplate: "file:///{path}", _serverName: "github", ... } - * // ] - * - * // Get templates from specific servers only - * const someTemplates = await client.getResourceTemplates({ servers: ['github'] }); - * - * // Original template name "file" becomes "github__file" - * // This prevents clashes if multiple servers have templates with the same name - * ``` - */ - async getResourceTemplates(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 resource templates from all healthy servers in parallel. - const results = await Promise.allSettled( - healthyServers.map(async (serverName) => ({ - serverName, - templates: await this.#getResourceTemplatesByServer(serverName), - })), - ); - - // Process results and transform template names. - const allTemplates: ResourceTemplate[] = results.flatMap((result) => { - if (result.status === "fulfilled") { - const { serverName, templates } = result.value; - return templates.map((template) => ({ - ...template, - name: `${serverName}__${template.name}`, - _serverName: serverName, - _templateName: template.name, - })); - } - // Silently skip failed servers - they're already filtered by health checks - return []; - }); - - return allTemplates; - } - - /** - * Read resource content using a namespaced resource name. - * - * This method uses an internal cache to map namespaced names to their server and URI. - * If the resource is not found in cache, it automatically calls getResources() to - * populate the cache and retries the lookup. - * - * @param namespacedName - The namespaced resource name (serverName__resourceName) - * @returns Array of resource contents (text or blob) - * @throws {Error} If the resource is not found after populating the cache - * @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 - * - * @example - * ```typescript - * // Read content by namespaced name - cache populated automatically if needed - * const contents = await client.readResource("github__readme"); - * for (const content of contents) { - * if (content.text) { - * console.log(content.text); - * } else if (content.blob) { - * console.log('Binary content (base64):', content.blob.substring(0, 50) + '...'); - * } - * } - * - * // You can also pre-populate the cache for efficiency if reading multiple resources - * await client.getResources(); - * const readme = await client.readResource("github__readme"); - * const changelog = await client.readResource("github__changelog"); - * ``` - */ - async readResource(namespacedName: string): Promise { - // Look up resource in cache. - let cached = this.#resourceCache.get(namespacedName); - - // If not in cache, populate it by calling getResources(). - if (!cached) { - await this.getResources(); - cached = this.#resourceCache.get(namespacedName); - - // If still not found after populating cache, resource doesn't exist. - if (!cached) { - throw new Error( - `Resource '${namespacedName}' not found. ` + - `Use getResources() to see available resources.`, - ); - } - } - - const { serverName, uri } = cached; - - // Read resource content from server. - return this.#readResourceByServer(serverName, uri); - } - /** * Internal method to get tool schemas for a server. * Used by dependency injection for ServersNamespace and internally for getAgentTools. @@ -1145,21 +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); - } - } - // Silently skip failed servers - they're already filtered by health checks - } + ), + ); + }); return agentTools; } diff --git a/src/types.ts b/src/types.ts index d63b02a..fc45b81 100644 --- a/src/types.ts +++ b/src/types.ts @@ -318,10 +318,6 @@ export interface Resource { description?: string; mimeType?: string; _meta?: Record; - // SDK additions for client-level aggregation. - _serverName?: string; - _resourceName?: string; - _uri?: string; } /** @@ -398,9 +394,6 @@ export interface ResourceTemplate { description?: string; mimeType?: string; _meta?: Record; - // SDK additions for client-level aggregation. - _serverName?: string; - _templateName?: string; } /** diff --git a/tests/unit/apiSurface.test.ts b/tests/unit/apiSurface.test.ts index 4b47a5f..fafd140 100644 --- a/tests/unit/apiSurface.test.ts +++ b/tests/unit/apiSurface.test.ts @@ -243,46 +243,6 @@ describe("API Surface - Complete Test Coverage", () => { expect(result).toEqual({ result: "12:00" }); }); - it("client.getTools() - 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.getTools(); - expect(schemas[0]?.name).toBe("time__get_time"); - }); - - it("client.getTools(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.getTools({ 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 799d4c8..4636ac5 100644 --- a/tests/unit/client.test.ts +++ b/tests/unit/client.test.ts @@ -122,190 +122,6 @@ describe("McpdClient", () => { }); }); - describe("getTools()", () => { - 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.getTools(); - - 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.getTools({ 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.getTools(); - - 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.getTools(); - - // 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,1160 +375,96 @@ 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 }], - }, + describe("generatePrompt()", () => { + it("should generate prompt with arguments", async () => { + const mockResponse = { + description: "A pull request for fixing a bug", + messages: [ + { role: "user", content: "Create PR: Fix bug" }, + { role: "assistant", content: "I'll help create that PR" }, ], }; - // First call: listServers() - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ["github", "notion"], - }); - - // Second call: health check for all servers + // First call: health check for server 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", - }, - ], + name: "github", + status: "ok", + latency: "2ms", + lastChecked: "2025-10-07T15:00:00Z", + lastSuccessful: "2025-10-07T15:00:00Z", }), }); - // Third+Fourth calls: prompts for 'github' and 'notion' (parallel) + // Second call: generate prompt mockFetch.mockResolvedValueOnce({ ok: true, - json: async () => ({ prompts: mockPrompts.github }), + json: async () => mockResponse, }); - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ prompts: mockPrompts.notion }), + const result = await client.generatePrompt("github__create_pr", { + title: "Fix bug", + description: "Fixed the authentication issue", }); - const prompts = await client.getPrompts(); + expect(result.description).toBe("A pull request for fixing a bug"); + expect(result.messages).toHaveLength(2); + expect(mockFetch).toHaveBeenLastCalledWith( + "http://localhost:8090/api/v1/servers/github/prompts/create_pr", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + arguments: { + title: "Fix bug", + description: "Fixed the authentication issue", + }, + }), + }), + ); + }); - 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 throw error for invalid prompt name format", async () => { + await expect(client.generatePrompt("invalid")).rejects.toThrow( + "Invalid prompt name format: invalid. Expected format: serverName__promptName", + ); }); - it("should filter prompts by specified servers", async () => { - const mockPrompts = { - github: [ - { - name: "create_pr", - description: "Create a pull request", - arguments: [], - }, - ], + it("should handle prompt names with underscores", async () => { + const mockResponse = { + description: "Test prompt", + messages: [], }; - // First call: health check for all servers + // First call: health check 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: "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() + // Second call: generate prompt mockFetch.mockResolvedValueOnce({ ok: true, - json: async () => [], + json: async () => mockResponse, }); - // Second call: health check for all servers (empty) - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - servers: [], - }), + await client.generatePrompt("github__create_pull_request", { + title: "Test", }); - 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 = { - description: "A pull request for fixing a bug", - messages: [ - { role: "user", content: "Create PR: Fix bug" }, - { role: "assistant", content: "I'll help create that PR" }, - ], - }; - - // First call: health check for server - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - name: "github", - status: "ok", - latency: "2ms", - lastChecked: "2025-10-07T15:00:00Z", - lastSuccessful: "2025-10-07T15:00:00Z", - }), - }); - - // Second call: generate prompt - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => mockResponse, - }); - - const result = await client.generatePrompt("github__create_pr", { - title: "Fix bug", - description: "Fixed the authentication issue", - }); - - expect(result.description).toBe("A pull request for fixing a bug"); - expect(result.messages).toHaveLength(2); - expect(mockFetch).toHaveBeenLastCalledWith( - "http://localhost:8090/api/v1/servers/github/prompts/create_pr", - expect.objectContaining({ - method: "POST", - body: JSON.stringify({ - arguments: { - title: "Fix bug", - description: "Fixed the authentication issue", - }, - }), - }), - ); - }); - - it("should throw error for invalid prompt name format", async () => { - await expect(client.generatePrompt("invalid")).rejects.toThrow( - "Invalid prompt name format: invalid. Expected format: serverName__promptName", - ); - }); - - it("should handle prompt names with underscores", async () => { - const mockResponse = { - description: "Test prompt", - messages: [], - }; - - // First call: health check - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - name: "github", - status: "ok", - latency: "2ms", - lastChecked: "2025-10-07T15:00:00Z", - lastSuccessful: "2025-10-07T15:00:00Z", - }), - }); - - // Second call: generate prompt - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => mockResponse, - }); - - await client.generatePrompt("github__create_pull_request", { - title: "Test", - }); - - expect(mockFetch).toHaveBeenLastCalledWith( - "http://localhost:8090/api/v1/servers/github/prompts/create_pull_request", - expect.any(Object), - ); - }); - }); - - describe("getResources()", () => { - it("should return all resources from all servers with transformed names", async () => { - const mockResources = { - files: [ - { - uri: "file:///Users/test/doc.txt", - name: "doc.txt", - description: "A text document", - mimeType: "text/plain", - }, - { - uri: "file:///Users/test/image.png", - name: "image.png", - description: "An image file", - mimeType: "image/png", - }, - ], - web: [ - { - uri: "https://example.com/page", - name: "example_page", - description: "Example web page", - mimeType: "text/html", - }, - ], - }; - - // First call: listServers() - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ["files", "web"], - }); - - // Second call: health check for all servers - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - servers: [ - { - name: "files", - status: "ok", - latency: "2ms", - lastChecked: "2025-10-07T15:00:00Z", - lastSuccessful: "2025-10-07T15:00:00Z", - }, - { - name: "web", - status: "ok", - latency: "1ms", - lastChecked: "2025-10-07T15:00:00Z", - lastSuccessful: "2025-10-07T15:00:00Z", - }, - ], - }), - }); - - // Third+Fourth calls: resources for 'files' and 'web' (parallel) - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ resources: mockResources.files }), - }); - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ resources: mockResources.web }), - }); - - const resources = await client.getResources(); - - expect(resources).toHaveLength(3); - expect(resources[0]?.name).toBe("files__doc.txt"); - expect(resources[0]?._serverName).toBe("files"); - expect(resources[0]?._resourceName).toBe("doc.txt"); - expect(resources[0]?._uri).toBe("file:///Users/test/doc.txt"); - expect(resources[1]?.name).toBe("files__image.png"); - expect(resources[2]?.name).toBe("web__example_page"); - }); - - it("should filter resources by specified servers", async () => { - const mockResources = { - files: [ - { - uri: "file:///Users/test/doc.txt", - name: "doc.txt", - description: "A text document", - mimeType: "text/plain", - }, - ], - }; - - // First call: health check for all servers - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - servers: [ - { - name: "files", - status: "ok", - latency: "2ms", - lastChecked: "2025-10-07T15:00:00Z", - lastSuccessful: "2025-10-07T15:00:00Z", - }, - ], - }), - }); - - // Second call: resources for 'files' - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ resources: mockResources.files }), - }); - - const resources = await client.getResources({ servers: ["files"] }); - - expect(resources).toHaveLength(1); - expect(resources[0]?.name).toBe("files__doc.txt"); - }); - - it("should return empty array when no resources 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 resources = await client.getResources(); - - expect(resources).toHaveLength(0); - }); - - it("should skip servers that return 501 Not Implemented", async () => { - const mockResources = { - files: [ - { - uri: "file:///Users/test/doc.txt", - name: "doc.txt", - description: "A text document", - mimeType: "text/plain", - }, - ], - }; - - // First call: listServers() - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ["files", "no-resources"], - }); - - // Second call: health check for all servers - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - servers: [ - { - name: "files", - status: "ok", - latency: "2ms", - lastChecked: "2025-10-07T15:00:00Z", - lastSuccessful: "2025-10-07T15:00:00Z", - }, - { - name: "no-resources", - status: "ok", - latency: "1ms", - lastChecked: "2025-10-07T15:00:00Z", - lastSuccessful: "2025-10-07T15:00:00Z", - }, - ], - }), - }); - - // Third call: resources for 'files' (success) - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ resources: mockResources.files }), - }); - - // Fourth call: resources for 'no-resources' (501 error) - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 501, - statusText: "Not Implemented", - text: async () => - JSON.stringify({ - detail: "Server does not support resources", - status: 501, - title: "Not Implemented", - type: "about:blank", - }), - }); - - const resources = await client.getResources(); - - // Should only get resources from server that supports them - expect(resources).toHaveLength(1); - expect(resources[0]?.name).toBe("files__doc.txt"); - }); - - it("should skip unhealthy servers", async () => { - const mockResources = { - files: [ - { - uri: "file:///Users/test/doc.txt", - name: "doc.txt", - description: "A text document", - mimeType: "text/plain", - }, - ], - }; - - // First call: listServers() - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ["files", "unhealthy"], - }); - - // Second call: health check for all servers - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - servers: [ - { - name: "files", - 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: resources for 'files' (unhealthy server is filtered out) - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ resources: mockResources.files }), - }); - - const resources = await client.getResources(); - - expect(resources).toHaveLength(1); - expect(resources[0]?.name).toBe("files__doc.txt"); - }); - }); - - describe("getResourceTemplates()", () => { - it("should return all resource templates from all servers with transformed names", async () => { - const mockTemplates = { - files: [ - { - uriTemplate: "file:///Users/{username}/docs/{filename}", - name: "user_doc", - description: "User document template", - mimeType: "text/plain", - }, - ], - web: [ - { - uriTemplate: "https://example.com/{path}", - name: "web_page", - description: "Web page template", - mimeType: "text/html", - }, - ], - }; - - // First call: listServers() - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ["files", "web"], - }); - - // Second call: health check for all servers - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - servers: [ - { - name: "files", - status: "ok", - latency: "2ms", - lastChecked: "2025-10-07T15:00:00Z", - lastSuccessful: "2025-10-07T15:00:00Z", - }, - { - name: "web", - status: "ok", - latency: "1ms", - lastChecked: "2025-10-07T15:00:00Z", - lastSuccessful: "2025-10-07T15:00:00Z", - }, - ], - }), - }); - - // Third+Fourth calls: templates for 'files' and 'web' (parallel) - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ templates: mockTemplates.files }), - }); - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ templates: mockTemplates.web }), - }); - - const templates = await client.getResourceTemplates(); - - expect(templates).toHaveLength(2); - expect(templates[0]?.name).toBe("files__user_doc"); - expect(templates[0]?._serverName).toBe("files"); - expect(templates[0]?._templateName).toBe("user_doc"); - expect(templates[1]?.name).toBe("web__web_page"); - }); - - it("should filter resource templates by specified servers", async () => { - const mockTemplates = { - files: [ - { - uriTemplate: "file:///Users/{username}/docs/{filename}", - name: "user_doc", - description: "User document template", - mimeType: "text/plain", - }, - ], - }; - - // First call: health check for all servers - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - servers: [ - { - name: "files", - status: "ok", - latency: "2ms", - lastChecked: "2025-10-07T15:00:00Z", - lastSuccessful: "2025-10-07T15:00:00Z", - }, - ], - }), - }); - - // Second call: templates for 'files' - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ templates: mockTemplates.files }), - }); - - const templates = await client.getResourceTemplates({ - servers: ["files"], - }); - - expect(templates).toHaveLength(1); - expect(templates[0]?.name).toBe("files__user_doc"); - }); - - it("should skip servers that return 501 Not Implemented", async () => { - const mockTemplates = { - files: [ - { - uriTemplate: "file:///Users/{username}/docs/{filename}", - name: "user_doc", - description: "User document template", - mimeType: "text/plain", - }, - ], - }; - - // First call: listServers() - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ["files", "no-templates"], - }); - - // Second call: health check for all servers - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - servers: [ - { - name: "files", - status: "ok", - latency: "2ms", - lastChecked: "2025-10-07T15:00:00Z", - lastSuccessful: "2025-10-07T15:00:00Z", - }, - { - name: "no-templates", - status: "ok", - latency: "1ms", - lastChecked: "2025-10-07T15:00:00Z", - lastSuccessful: "2025-10-07T15:00:00Z", - }, - ], - }), - }); - - // Third call: templates for 'files' (success) - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ templates: mockTemplates.files }), - }); - - // Fourth call: templates for 'no-templates' (501 error) - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 501, - statusText: "Not Implemented", - text: async () => - JSON.stringify({ - detail: "Server does not support resource templates", - status: 501, - title: "Not Implemented", - type: "about:blank", - }), - }); - - const templates = await client.getResourceTemplates(); - - // Should only get templates from server that supports them - expect(templates).toHaveLength(1); - expect(templates[0]?.name).toBe("files__user_doc"); - }); - - it("should skip unhealthy servers", async () => { - const mockTemplates = { - files: [ - { - uriTemplate: "file:///Users/{username}/docs/{filename}", - name: "user_doc", - description: "User document template", - mimeType: "text/plain", - }, - ], - }; - - // First call: listServers() - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ["files", "unhealthy"], - }); - - // Second call: health check for all servers - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - servers: [ - { - name: "files", - 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: templates for 'files' (unhealthy server is filtered out) - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ templates: mockTemplates.files }), - }); - - const templates = await client.getResourceTemplates(); - - expect(templates).toHaveLength(1); - expect(templates[0]?.name).toBe("files__user_doc"); - }); - }); - - describe("readResource()", () => { - it("should auto-populate cache and read resource content", async () => { - const mockResources = [ - { - uri: "file:///Users/test/doc.txt", - name: "doc.txt", - description: "A text document", - mimeType: "text/plain", - }, - ]; - - const mockContent = [ - { - uri: "file:///Users/test/doc.txt", - text: "Hello, world!", - mimeType: "text/plain", - }, - ]; - - // First call: listServers() (triggered by auto-populate) - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ["files"], - }); - - // Second call: health check (triggered by auto-populate) - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - servers: [ - { - name: "files", - status: "ok", - latency: "2ms", - lastChecked: "2025-10-07T15:00:00Z", - lastSuccessful: "2025-10-07T15:00:00Z", - }, - ], - }), - }); - - // Third call: getResources (auto-populate cache) - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ resources: mockResources }), - }); - - // Fourth call: read resource content (health check is cached from step 2) - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => mockContent, - }); - - const content = await client.readResource("files__doc.txt"); - - expect(content).toHaveLength(1); - expect(content[0]?.text).toBe("Hello, world!"); - expect(content[0]?.mimeType).toBe("text/plain"); - expect(mockFetch).toHaveBeenLastCalledWith( - expect.stringContaining( - "/api/v1/servers/files/resources/content?uri=file%3A%2F%2F%2FUsers%2Ftest%2Fdoc.txt", - ), - expect.any(Object), - ); - }); - - it("should read from pre-populated cache", async () => { - const mockResources = [ - { - uri: "file:///Users/test/doc.txt", - name: "doc.txt", - description: "A text document", - mimeType: "text/plain", - }, - ]; - - const mockContent = [ - { - uri: "file:///Users/test/doc.txt", - text: "Hello, world!", - mimeType: "text/plain", - }, - ]; - - // First call: listServers() - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ["files"], - }); - - // Second call: health check - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - servers: [ - { - name: "files", - status: "ok", - latency: "2ms", - lastChecked: "2025-10-07T15:00:00Z", - lastSuccessful: "2025-10-07T15:00:00Z", - }, - ], - }), - }); - - // Third call: getResources (pre-populate cache) - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ resources: mockResources }), - }); - - // Pre-populate the cache - await client.getResources(); - - // Fourth call: read resource content (health check is cached from step 2) - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => mockContent, - }); - - const content = await client.readResource("files__doc.txt"); - - expect(content).toHaveLength(1); - expect(content[0]?.text).toBe("Hello, world!"); - // Should only have 4 fetch calls total (not 7 if it auto-populated again) - expect(mockFetch).toHaveBeenCalledTimes(4); - }); - - it("should handle blob content", async () => { - const mockResources = [ - { - uri: "file:///Users/test/image.png", - name: "image.png", - description: "An image file", - mimeType: "image/png", - }, - ]; - - const mockContent = [ - { - uri: "file:///Users/test/image.png", - blob: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", - mimeType: "image/png", - }, - ]; - - // First call: listServers() - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ["files"], - }); - - // Second call: health check - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - servers: [ - { - name: "files", - status: "ok", - latency: "2ms", - lastChecked: "2025-10-07T15:00:00Z", - lastSuccessful: "2025-10-07T15:00:00Z", - }, - ], - }), - }); - - // Third call: getResources (pre-populate cache) - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ resources: mockResources }), - }); - - await client.getResources(); - - // Fourth call: read resource content (health check is cached from step 2) - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => mockContent, - }); - - const content = await client.readResource("files__image.png"); - - expect(content).toHaveLength(1); - expect(content[0]?.blob).toBeDefined(); - expect(content[0]?.mimeType).toBe("image/png"); - }); - - it("should throw error when resource not found after cache population", async () => { - const mockResources = [ - { - uri: "file:///Users/test/doc.txt", - name: "doc.txt", - description: "A text document", - mimeType: "text/plain", - }, - ]; - - // First call: listServers() - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ["files"], - }); - - // Second call: health check - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - servers: [ - { - name: "files", - status: "ok", - latency: "2ms", - lastChecked: "2025-10-07T15:00:00Z", - lastSuccessful: "2025-10-07T15:00:00Z", - }, - ], - }), - }); - - // Third call: getResources (auto-populate cache) - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ resources: mockResources }), - }); - - await expect( - client.readResource("files__nonexistent.txt"), - ).rejects.toThrow( - "Resource 'files__nonexistent.txt' not found. Use getResources() to see available resources.", - ); - }); - - it("should throw error for invalid resource name format", async () => { - // First call: listServers() (triggered by auto-populate) - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => [], - }); - - // Second call: health check (triggered by auto-populate) - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - servers: [], - }), - }); - - await expect(client.readResource("invalid")).rejects.toThrow( - "Resource 'invalid' not found. Use getResources() to see available resources.", - ); - }); - - it("should handle resource names with underscores", async () => { - const mockResources = [ - { - uri: "file:///Users/test/my_file.txt", - name: "my_file.txt", - description: "A file with underscores", - mimeType: "text/plain", - }, - ]; - - const mockContent = [ - { - uri: "file:///Users/test/my_file.txt", - text: "Content", - mimeType: "text/plain", - }, - ]; - - // First call: listServers() - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ["files"], - }); - - // Second call: health check - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - servers: [ - { - name: "files", - status: "ok", - latency: "2ms", - lastChecked: "2025-10-07T15:00:00Z", - lastSuccessful: "2025-10-07T15:00:00Z", - }, - ], - }), - }); - - // Third call: getResources - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ resources: mockResources }), - }); - - await client.getResources(); - - // Fourth call: read resource content (health check is cached from step 2) - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => mockContent, - }); - - await client.readResource("files__my_file.txt"); - - expect(mockFetch).toHaveBeenLastCalledWith( - expect.stringContaining( - "/api/v1/servers/files/resources/content?uri=file%3A%2F%2F%2FUsers%2Ftest%2Fmy_file.txt", - ), - expect.any(Object), - ); + expect(mockFetch).toHaveBeenLastCalledWith( + "http://localhost:8090/api/v1/servers/github/prompts/create_pull_request", + expect.any(Object), + ); }); }); -}); -describe("HealthStatusHelpers", () => { describe("isHealthy()", () => { it("should return true for ok status", () => { expect(HealthStatusHelpers.isHealthy("ok")).toBe(true);