From c79af6b950b221f67bfa857c221dbf396f059943 Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Fri, 17 Oct 2025 14:17:21 +0100 Subject: [PATCH 1/6] Add prompts support to SDK * Prompts can be accessed via dynamic proxy syntax (client.servers.foo.prompts.bar()) or explicit methods. * Client-level methods namespace prompt names as serverName__promptName to prevent collisions across multiple servers. * API documentation in README. --- README.md | 102 ++++++++++ src/apiPaths.ts | 8 + src/client.ts | 229 +++++++++++++++++++++ src/dynamicCaller.ts | 210 ++++++++++++++++++- src/types.ts | 18 ++ tests/unit/client.test.ts | 340 +++++++++++++++++++++++++++++++ tests/unit/dynamicCaller.test.ts | 238 ++++++++++++++++++++++ 7 files changed, 1142 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ba8fb5f..4e6a49f 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,108 @@ 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). + +```typescript +// Generate a prompt with namespaced name +const result = await client.generatePrompt("github__create_pr", { + title: "Fix bug", + description: "Fixed authentication issue", +}); +// Returns: { messages: [...], description: "...", ... } +``` + +#### `client.servers..getPrompts()` + +Returns prompt schemas for a specific server. + +```typescript +// Get prompts for a specific server +const githubPrompts = await client.servers.github.getPrompts(); +// Returns: [{ name: 'create_pr', description: '...', arguments: [...] }] +``` + +#### `client.servers..prompts.(args)` + +Dynamically generate any prompt using natural syntax via the `.prompts` namespace. Prompt names must match exactly as returned by the MCP server. + +```typescript +// Generate a prompt with parameters using property access (recommended) +const result = await client.servers.github.prompts.create_pr({ + title: "Fix bug", + description: "Fixed authentication issue", +}); + +// Generate without parameters (if prompt has no required args) +const result = await client.servers.templates.prompts.default_template(); +``` + +#### `client.servers..generatePrompt(promptName, args?)` + +Generate a prompt by name with the given arguments. This is useful for programmatic prompt generation when the prompt name is in a variable. + +```typescript +// Generate with dynamic prompt name +const promptName = "create_pr"; +const result = await client.servers.github.generatePrompt(promptName, { + title: "Fix bug", + description: "Fixed authentication issue", +}); + +// Using with dynamic server name too +const serverName = "github"; +const result2 = await client.servers[serverName].generatePrompt(promptName, { + title: "Fix bug", +}); +``` + +#### `client.servers..hasPrompt(promptName)` + +Check if a specific prompt exists on a server. Prompt names must match exactly as returned by the MCP server. + +```typescript +// Check if prompt exists before generating it +if (await client.servers.github.hasPrompt("create_pr")) { + const result = await client.servers.github.generatePrompt("create_pr", { + title: "Fix bug", + }); +} + +// Using with dynamic server names +const serverName = "github"; +if (await client.servers[serverName].hasPrompt("create_pr")) { + const result = await client.servers[serverName].prompts.create_pr({ + title: "Fix bug", + }); +} +``` + #### `client.getServerHealth(serverName?: string)` Get health information for one or all servers. diff --git a/src/apiPaths.ts b/src/apiPaths.ts index 4eaf620..a49eef0 100644 --- a/src/apiPaths.ts +++ b/src/apiPaths.ts @@ -17,6 +17,14 @@ export const API_PATHS = { TOOL_CALL: (serverName: string, toolName: string) => `${SERVERS_BASE}/${encodeURIComponent(serverName)}/tools/${encodeURIComponent(toolName)}`, + // Prompts + SERVER_PROMPTS: (serverName: string, cursor?: string) => { + const base = `${SERVERS_BASE}/${encodeURIComponent(serverName)}/prompts`; + return cursor ? `${base}?cursor=${encodeURIComponent(cursor)}` : base; + }, + PROMPT_GET_GENERATED: (serverName: string, promptName: string) => + `${SERVERS_BASE}/${encodeURIComponent(serverName)}/prompts/${encodeURIComponent(promptName)}`, + // Health HEALTH_ALL: HEALTH_SERVERS_BASE, HEALTH_SERVER: (serverName: string) => diff --git a/src/client.ts b/src/client.ts index 150ecdb..fd1e571 100644 --- a/src/client.ts +++ b/src/client.ts @@ -29,6 +29,10 @@ import { HealthResponse, ErrorModel, AgentToolsOptions, + Prompt, + Prompts, + GeneratePromptResponseBody, + PromptGenerateArguments, } from "./types"; import { createCache } from "./utils/cache"; import { ServersNamespace } from "./dynamicCaller"; @@ -108,6 +112,8 @@ export class McpdClient { this.servers = new ServersNamespace( this.#performCall.bind(this), this.#getToolsByServer.bind(this), + this.#generatePromptInternal.bind(this), + this.#getPromptsByServer.bind(this), ); this.#functionBuilder = new FunctionBuilder(this.#performCall.bind(this)); } @@ -335,6 +341,152 @@ export class McpdClient { 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 || {}; + + // Determine which servers to query. + const serverNames = + servers && servers.length > 0 ? servers : await this.listServers(); + + // Get health status for all servers. + const healthMap = await this.getServerHealth(); + + // Filter to only healthy servers. + const healthyServers = serverNames.filter((name) => { + const health = healthMap[name]; + return health && HealthStatusHelpers.isHealthy(health.status); + }); + + // 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[] = []; + for (const result of results) { + if (result.status === "fulfilled") { + const { serverName, prompts } = result.value; + // Transform prompt names to serverName__promptName format. + for (const prompt of prompts) { + allPrompts.push({ + ...prompt, + name: `${serverName}__${prompt.name}`, + }); + } + } else { + // If we can't get prompts for a server, skip it with a warning. + console.warn(`Failed to get prompts for server:`, result.reason); + } + } + + return allPrompts; + } + + /** + * Generate a prompt from a template with the given arguments. + * + * IMPORTANT: The promptName must be in the format `serverName__promptName`. + * This is the same format returned by getPrompts(). + * + * @param promptName - The fully qualified prompt name (serverName__promptName) + * @param args - Arguments to pass to the prompt template + * @returns The generated prompt response with description and messages + * @throws {Error} If the prompt name format is invalid + * @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 + * // First, get available prompts + * const prompts = await client.getPrompts(); + * // prompts = [{ name: "github__create_pr", ... }] + * + * // Generate a prompt + * const result = await client.generatePrompt("github__create_pr", { + * title: "Fix bug", + * description: "Fixed the authentication issue" + * }); + * console.log(result.messages); // Array of prompt messages + * ``` + */ + async generatePrompt( + promptName: string, + args?: Record, + ): Promise { + // Parse the serverName__promptName format. + const parts = promptName.split("__"); + if (parts.length < 2 || !parts[0] || !parts[1]) { + throw new Error( + `Invalid prompt name format: ${promptName}. Expected format: serverName__promptName`, + ); + } + + const serverName: string = parts[0]; + const actualPromptName: string = parts.slice(1).join("__"); + + // Check server health first. + await this.#ensureServerHealthy(serverName); + + const path = API_PATHS.PROMPT_GET_GENERATED(serverName, actualPromptName); + const requestBody: PromptGenerateArguments = { + arguments: args || {}, + }; + + const response = await this.#request(path, { + method: "POST", + body: JSON.stringify(requestBody), + }); + + return response; + } + /** * Internal method to get tool schemas for a server. * Used by dependency injection for ServersNamespace and internally for getAgentTools. @@ -365,6 +517,83 @@ export class McpdClient { return response.tools; } + /** + * Internal method to get prompt schemas for a server. + * Used internally for getPromptSchemas. + * + * @param serverName - Server name to get prompts for + * @param cursor - Optional cursor for pagination + * @returns Prompt 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 #getPromptsByServer( + serverName: string, + cursor?: string, + ): Promise { + // Check server health first. + await this.#ensureServerHealthy(serverName); + + const path = API_PATHS.SERVER_PROMPTS(serverName, cursor); + + try { + const response = await this.#request(path); + return response.prompts || []; + } catch (error) { + // Handle 501 Not Implemented - server doesn't support prompts. + if ( + error instanceof McpdError && + error.message.includes("501") && + error.message.includes("Not Implemented") + ) { + return []; + } + + throw error; + } + } + + /** + * Internal method to generate a prompt on a server. + * + * ⚠️ This method is truly private and cannot be accessed by SDK consumers. + * Use the fluent API instead: `client.servers.foo.prompts.bar(args)` + * + * This method is used internally by: + * - PromptsNamespace (via dependency injection) + * - Server.getPrompt() (via dependency injection) + * + * @param serverName - The name of the server + * @param promptName - The exact name of the prompt + * @param args - The prompt arguments + * @returns The generated prompt response + * @internal + */ + async #generatePromptInternal( + serverName: string, + promptName: string, + args?: Record, + ): Promise { + // Check server health first. + await this.#ensureServerHealthy(serverName); + + const path = API_PATHS.PROMPT_GET_GENERATED(serverName, promptName); + const requestBody: PromptGenerateArguments = { + arguments: args || {}, + }; + + const response = await this.#request(path, { + method: "POST", + body: JSON.stringify(requestBody), + }); + + return response; + } + /** * Get health information for one or all servers. * diff --git a/src/dynamicCaller.ts b/src/dynamicCaller.ts index 145df8a..ff8813e 100644 --- a/src/dynamicCaller.ts +++ b/src/dynamicCaller.ts @@ -14,7 +14,15 @@ */ import { ToolNotFoundError } from "./errors"; -import type { Tool, PerformCallFn, GetToolsFn } from "./types"; +import type { + Tool, + Prompt, + GeneratePromptResponseBody, + PerformCallFn, + GetToolsFn, + GetPromptsFn, + GeneratePromptFn, +} from "./types"; /** * Namespace for accessing MCP servers via proxy. @@ -40,16 +48,27 @@ export class ServersNamespace { #performCall: PerformCallFn; #getTools: GetToolsFn; + #generatePrompt: GeneratePromptFn; + #getPrompts: GetPromptsFn; /** * Initialize the ServersNamespace with injected functions. * * @param performCall - Function to execute tool calls * @param getTools - Function to get tool schemas + * @param generatePrompt - Function to generate prompts + * @param getPrompts - Function to get prompt schemas */ - constructor(performCall: PerformCallFn, getTools: GetToolsFn) { + constructor( + performCall: PerformCallFn, + getTools: GetToolsFn, + generatePrompt: GeneratePromptFn, + getPrompts: GetPromptsFn, + ) { this.#performCall = performCall; this.#getTools = getTools; + this.#generatePrompt = generatePrompt; + this.#getPrompts = getPrompts; // Return a Proxy to intercept property access return new Proxy(this, { @@ -57,7 +76,13 @@ export class ServersNamespace { if (typeof serverName !== "string") { return undefined; } - return new Server(target.#performCall, target.#getTools, serverName); + return new Server( + target.#performCall, + target.#getTools, + target.#generatePrompt, + target.#getPrompts, + serverName, + ); }, }); } @@ -83,9 +108,12 @@ export class ServersNamespace { */ export class Server { readonly tools: ToolsNamespace; + readonly prompts: PromptsNamespace; #performCall: PerformCallFn; #getTools: GetToolsFn; + #generatePrompt: GeneratePromptFn; + #getPrompts: GetPromptsFn; #serverName: string; /** @@ -93,15 +121,21 @@ export class Server { * * @param performCall - Function to execute tool calls * @param getTools - Function to get tool schemas + * @param generatePrompt - Function to generate prompts + * @param getPrompts - Function to get prompt schemas * @param serverName - The name of the MCP server */ constructor( performCall: PerformCallFn, getTools: GetToolsFn, + generatePrompt: GeneratePromptFn, + getPrompts: GetPromptsFn, serverName: string, ) { this.#performCall = performCall; this.#getTools = getTools; + this.#generatePrompt = generatePrompt; + this.#getPrompts = getPrompts; this.#serverName = serverName; // Create the tools namespace as a real property. @@ -110,6 +144,13 @@ export class Server { this.#getTools, this.#serverName, ); + + // Create the prompts namespace as a real property. + this.prompts = new PromptsNamespace( + this.#generatePrompt, + this.#getPrompts, + this.#serverName, + ); } /** @@ -197,6 +238,92 @@ export class Server { // Perform the tool call return this.#performCall(this.#serverName, toolName, args); } + + /** + * Get all prompts available on this server. + * + * @returns Array of prompt schemas + * @throws {ServerNotFoundError} If the server doesn't exist + * @throws {ServerUnhealthyError} If the server is unhealthy + * + * @example + * ```typescript + * const prompts = await client.servers.github.getPrompts(); + * for (const prompt of prompts) { + * console.log(`${prompt.name}: ${prompt.description}`); + * } + * ``` + */ + async getPrompts(): Promise { + return this.#getPrompts(this.#serverName); + } + + /** + * Check if a prompt exists on this server. + * + * The prompt name must match exactly as returned by the server. + * + * @param promptName - The exact name of the prompt to check + * @returns True if the prompt exists, false otherwise + * + * @example + * ```typescript + * if (await client.servers.github.hasPrompt('create_pr')) { + * const result = await client.servers.github.generatePrompt('create_pr', { title: 'Fix bug' }); + * } + * ``` + */ + async hasPrompt(promptName: string): Promise { + try { + const prompts = await this.#getPrompts(this.#serverName); + return prompts.some((p) => p.name === promptName); + } catch { + return false; + } + } + + /** + * Generate a prompt by name with the given arguments. + * + * This method is useful for programmatic prompt generation when the prompt name + * is in a variable. The prompt name must match exactly as returned by the server. + * + * @param promptName - The exact name of the prompt to generate + * @param args - The arguments to pass to the prompt template + * @returns The generated prompt response + * @throws {ToolNotFoundError} If the prompt doesn't exist on the server + * + * @example + * ```typescript + * // Call with explicit method (useful for dynamic prompt names): + * const promptName = 'create_pr'; + * await client.servers.github.generatePrompt(promptName, { title: 'Fix bug' }); + * + * // Or with dynamic server name: + * const serverName = 'github'; + * await client.servers[serverName].generatePrompt(promptName, { title: 'Fix bug' }); + * ``` + */ + async generatePrompt( + promptName: string, + args?: Record, + ): Promise { + // Check if the prompt exists (exact match only). + const prompts = await this.#getPrompts(this.#serverName); + const prompt = prompts.find((p) => p.name === promptName); + + if (!prompt) { + throw new ToolNotFoundError( + `Prompt '${promptName}' not found on server '${this.#serverName}'. ` + + `Use client.servers.${this.#serverName}.getPrompts() to see available prompts.`, + this.#serverName, + promptName, + ); + } + + // Generate the prompt. + return this.#generatePrompt(this.#serverName, promptName, args); + } } /** @@ -270,3 +397,80 @@ export class ToolsNamespace { }); } } + +/** + * Namespace for accessing prompts on a specific MCP server via proxy. + * + * This class provides the `.prompts` namespace for a server, allowing you to generate + * prompts as if they were methods. All prompt names must match exactly as returned + * by the MCP server. + * + * NOTE: Use `client.servers.foo.generatePrompt()` and `client.servers.foo.hasPrompt()` + * instead of putting them in the `.prompts` namespace to avoid collisions with + * actual prompts named "generatePrompt" or "hasPrompt". + * + * @example + * ```typescript + * // Generate prompts via .prompts namespace with static names + * const result = await client.servers.github.prompts.create_pr({ + * title: "Fix bug", + * description: "Fixed auth issue" + * }); + * ``` + */ +export class PromptsNamespace { + [promptName: string]: ( + args?: Record, + ) => Promise; + + #generatePrompt: GeneratePromptFn; + #getPrompts: GetPromptsFn; + #serverName: string; + + /** + * Initialize a PromptsNamespace for a specific server. + * + * @param generatePrompt - Function to generate prompts + * @param getPrompts - Function to get prompt schemas + * @param serverName - The name of the MCP server + */ + constructor( + generatePrompt: GeneratePromptFn, + getPrompts: GetPromptsFn, + serverName: string, + ) { + this.#generatePrompt = generatePrompt; + this.#getPrompts = getPrompts; + this.#serverName = serverName; + + // Return a Proxy to intercept method calls. + return new Proxy(this, { + get: (target, prop: string | symbol) => { + if (typeof prop !== "string") { + return undefined; + } + + // Return a function that will generate the prompt with exact name matching. + return async (args?: Record) => { + const promptName = prop; + + // Check if the prompt exists (exact match only). + const prompts = await target.#getPrompts(target.#serverName); + const prompt = prompts.find((p) => p.name === promptName); + + if (!prompt) { + throw new ToolNotFoundError( + `Prompt '${promptName}' not found on server '${target.#serverName}'. ` + + `Use client.servers.${target.#serverName}.getPrompts() to see available prompts.`, + target.#serverName, + promptName, + ); + } + + // Generate the prompt. + return target.#generatePrompt(target.#serverName, promptName, args); + }; + }, + }); + } +} diff --git a/src/types.ts b/src/types.ts index 187fe24..76474f2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -265,6 +265,24 @@ export type PerformCallFn = ( */ export type GetToolsFn = (serverName: string) => Promise; +/** + * Function signature for getting prompt templates from a server. + * This is injected into proxy classes via dependency injection. + * @internal + */ +export type GetPromptsFn = (serverName: string) => Promise; + +/** + * Function signature for generating a prompt from a template. + * This is injected into proxy classes via dependency injection. + * @internal + */ +export type GeneratePromptFn = ( + serverName: string, + promptName: string, + args?: Record, +) => Promise; + /** * MCP resource definition. */ diff --git a/tests/unit/client.test.ts b/tests/unit/client.test.ts index ac8d9fc..284cebd 100644 --- a/tests/unit/client.test.ts +++ b/tests/unit/client.test.ts @@ -558,6 +558,346 @@ describe("McpdClient", () => { expect(() => client.clearAgentToolsCache()).not.toThrow(); }); }); + + describe("getPrompts()", () => { + it("should return all prompts from all servers with transformed names", async () => { + const mockPrompts = { + github: [ + { + name: "create_pr", + description: "Create a pull request", + arguments: [{ name: "title", required: true }], + }, + { + name: "close_issue", + description: "Close an issue", + arguments: [{ name: "number", required: true }], + }, + ], + notion: [ + { + name: "create_page", + description: "Create a new page", + arguments: [{ name: "title", required: true }], + }, + ], + }; + + // First call: listServers() + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ["github", "notion"], + }); + + // Second call: health check for all servers + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + servers: [ + { + name: "github", + status: "ok", + latency: "2ms", + lastChecked: "2025-10-07T15:00:00Z", + lastSuccessful: "2025-10-07T15:00:00Z", + }, + { + name: "notion", + status: "ok", + latency: "1ms", + lastChecked: "2025-10-07T15:00:00Z", + lastSuccessful: "2025-10-07T15:00:00Z", + }, + ], + }), + }); + + // Third+Fourth calls: prompts for 'github' and 'notion' (parallel) + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ prompts: mockPrompts.github }), + }); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ prompts: mockPrompts.notion }), + }); + + const prompts = await client.getPrompts(); + + expect(prompts).toHaveLength(3); + expect(prompts[0]?.name).toBe("github__create_pr"); + expect(prompts[1]?.name).toBe("github__close_issue"); + expect(prompts[2]?.name).toBe("notion__create_page"); + }); + + it("should filter prompts by specified servers", async () => { + const mockPrompts = { + github: [ + { + name: "create_pr", + description: "Create a pull request", + arguments: [], + }, + ], + }; + + // First call: health check for all servers + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + servers: [ + { + name: "github", + status: "ok", + latency: "2ms", + lastChecked: "2025-10-07T15:00:00Z", + lastSuccessful: "2025-10-07T15:00:00Z", + }, + ], + }), + }); + + // Second call: prompts for 'github' + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ prompts: mockPrompts.github }), + }); + + const prompts = await client.getPrompts({ servers: ["github"] }); + + expect(prompts).toHaveLength(1); + expect(prompts[0]?.name).toBe("github__create_pr"); + }); + + it("should return empty array when no prompts available", async () => { + // First call: listServers() + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => [], + }); + + // Second call: health check for all servers (empty) + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + servers: [], + }), + }); + + const prompts = await client.getPrompts(); + + expect(prompts).toHaveLength(0); + }); + + it("should skip servers that return 501 Not Implemented", async () => { + const mockPrompts = { + github: [ + { + name: "create_pr", + description: "Create a pull request", + arguments: [], + }, + ], + }; + + // First call: listServers() + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ["github", "no-prompts"], + }); + + // Second call: health check for all servers + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + servers: [ + { + name: "github", + status: "ok", + latency: "2ms", + lastChecked: "2025-10-07T15:00:00Z", + lastSuccessful: "2025-10-07T15:00:00Z", + }, + { + name: "no-prompts", + status: "ok", + latency: "1ms", + lastChecked: "2025-10-07T15:00:00Z", + lastSuccessful: "2025-10-07T15:00:00Z", + }, + ], + }), + }); + + // Third call: prompts for 'github' (success) + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ prompts: mockPrompts.github }), + }); + + // Fourth call: prompts for 'no-prompts' (501 error) + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 501, + statusText: "Not Implemented", + text: async () => + JSON.stringify({ + detail: "Server does not support prompts", + status: 501, + title: "Not Implemented", + type: "about:blank", + }), + }); + + const prompts = await client.getPrompts(); + + // Should only get prompts from server that supports them + expect(prompts).toHaveLength(1); + expect(prompts[0]?.name).toBe("github__create_pr"); + }); + + it("should skip unhealthy servers", async () => { + const mockPrompts = { + github: [ + { + name: "create_pr", + description: "Create a pull request", + arguments: [], + }, + ], + }; + + // First call: listServers() + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ["github", "unhealthy"], + }); + + // Second call: health check for all servers + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + servers: [ + { + name: "github", + status: "ok", + latency: "2ms", + lastChecked: "2025-10-07T15:00:00Z", + lastSuccessful: "2025-10-07T15:00:00Z", + }, + { + name: "unhealthy", + status: "error", + latency: "0ms", + lastChecked: "2025-10-07T15:00:00Z", + }, + ], + }), + }); + + // Third call: prompts for 'github' (unhealthy server is filtered out) + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ prompts: mockPrompts.github }), + }); + + const prompts = await client.getPrompts(); + + expect(prompts).toHaveLength(1); + expect(prompts[0]?.name).toBe("github__create_pr"); + }); + }); + + describe("generatePrompt()", () => { + it("should generate prompt with arguments", async () => { + const mockResponse = { + 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("HealthStatusHelpers", () => { diff --git a/tests/unit/dynamicCaller.test.ts b/tests/unit/dynamicCaller.test.ts index 6d86d29..9468583 100644 --- a/tests/unit/dynamicCaller.test.ts +++ b/tests/unit/dynamicCaller.test.ts @@ -523,4 +523,242 @@ describe("Dynamic Calling Patterns", () => { }); }); }); + + describe("Prompt Dynamic Calling Patterns", () => { + describe("Pattern: client.servers.foo.getPrompts()", () => { + it("should list prompts 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", + }), + }); + + // Prompts list. + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + prompts: [ + { name: "create_pr", description: "Create PR" }, + { name: "close_issue", description: "Close issue" }, + ], + }), + }); + + const prompts = await client.servers.github!.getPrompts(); + + expect(prompts).toHaveLength(2); + expect(prompts[0]?.name).toBe("create_pr"); + expect(prompts[1]?.name).toBe("close_issue"); + }); + }); + + describe("Pattern: client.servers.foo!.prompts.bar!(args)", () => { + it("should generate prompt 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", + }), + }); + + // Prompts list. + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + prompts: [ + { + name: "create_pr", + description: "Create PR", + arguments: [{ name: "title", required: true }], + }, + ], + }), + }); + + // Prompt generation (health check is cached from above). + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + description: "PR for bug fix", + messages: [{ role: "user", content: "Create PR: Fix bug" }], + }), + }); + + const result = await client.servers.github!.prompts.create_pr!({ + title: "Fix bug", + }); + + expect(result.description).toBe("PR for bug fix"); + expect(result.messages).toHaveLength(1); + expect(mockFetch).toHaveBeenCalledWith( + "http://localhost:8090/api/v1/servers/github/prompts/create_pr", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ arguments: { title: "Fix bug" } }), + }), + ); + }); + + it("should throw ToolNotFoundError for non-existent prompt", 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", + }), + }); + + // Prompts list (no prompts). + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + prompts: [], + }), + }); + + await expect( + client.servers.github!.prompts.nonexistent_prompt!(), + ).rejects.toThrow(ToolNotFoundError); + }); + }); + + describe("Pattern: client.servers.foo.generatePrompt(name, args)", () => { + it("should generate prompt with dynamic prompt name", async () => { + const promptName = "create_pr"; + + // Health check. + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + status: "ok", + latency: "2ms", + lastChecked: "2025-10-07T15:00:00Z", + lastSuccessful: "2025-10-07T15:00:00Z", + }), + }); + + // Prompts list. + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + prompts: [ + { + name: "create_pr", + description: "Create PR", + arguments: [{ name: "title", required: true }], + }, + ], + }), + }); + + // Prompt generation (health check is cached from above). + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + description: "PR for bug fix", + messages: [{ role: "user", content: "Create PR: Fix bug" }], + }), + }); + + const result = await client.servers.github!.generatePrompt(promptName, { + title: "Fix bug", + }); + + expect(result.description).toBe("PR for bug fix"); + expect(result.messages).toHaveLength(1); + }); + + it("should throw ToolNotFoundError for non-existent prompt", async () => { + const promptName = "nonexistent_prompt"; + + // Health check. + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + status: "ok", + latency: "2ms", + lastChecked: "2025-10-07T15:00:00Z", + lastSuccessful: "2025-10-07T15:00:00Z", + }), + }); + + // Prompts list (no matching prompt). + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + prompts: [{ name: "create_pr", description: "Create PR" }], + }), + }); + + await expect( + client.servers.github!.generatePrompt(promptName), + ).rejects.toThrow(ToolNotFoundError); + }); + }); + + describe("Pattern: client.servers.foo.hasPrompt(name)", () => { + it("should return true when prompt 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", + }), + }); + + // Prompts list. + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + prompts: [{ name: "create_pr", description: "Create PR" }], + }), + }); + + const exists = await client.servers.github!.hasPrompt("create_pr"); + + expect(exists).toBe(true); + }); + + it("should return false when prompt 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", + }), + }); + + // Prompts list. + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + prompts: [{ name: "create_pr", description: "Create PR" }], + }), + }); + + const exists = + await client.servers.github!.hasPrompt("nonexistent_prompt"); + + expect(exists).toBe(false); + }); + }); + }); }); From 8ecbe0ea0464459649cd350ff5cd7a4241ba7d66 Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Fri, 17 Oct 2025 15:41:32 +0100 Subject: [PATCH 2/6] Update src/client.ts Co-authored-by: Khaled Osman --- src/client.ts | 28 ++++++++++++---------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/src/client.ts b/src/client.ts index fd1e571..8cdb230 100644 --- a/src/client.ts +++ b/src/client.ts @@ -406,22 +406,18 @@ export class McpdClient { ); // Process results and transform prompt names. - const allPrompts: Prompt[] = []; - for (const result of results) { - if (result.status === "fulfilled") { - const { serverName, prompts } = result.value; - // Transform prompt names to serverName__promptName format. - for (const prompt of prompts) { - allPrompts.push({ - ...prompt, - name: `${serverName}__${prompt.name}`, - }); - } - } else { - // If we can't get prompts for a server, skip it with a warning. - console.warn(`Failed to get prompts for server:`, result.reason); - } - } +const allPrompts: Prompt[] = results.flatMap(result => { + if (result.status === "fulfilled") { + const { serverName, prompts } = result.value; + return prompts.map(prompt => ({ + ...prompt, + name: `${serverName}__${prompt.name}`, + })); + } else { + console.warn(`Failed to get prompts for server:`, result.reason); + return []; // Return an empty array for rejected promises + } +}); return allPrompts; } From 19e80883013d1a41161b2b6530048252eb53d434 Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Fri, 17 Oct 2025 15:50:33 +0100 Subject: [PATCH 3/6] PR feedbaclk --- src/client.ts | 110 ++++++++++++++++++++----------------------- src/dynamicCaller.ts | 61 ++++++++++++++++++------ 2 files changed, 98 insertions(+), 73 deletions(-) diff --git a/src/client.ts b/src/client.ts index 8cdb230..58491d2 100644 --- a/src/client.ts +++ b/src/client.ts @@ -109,12 +109,12 @@ export class McpdClient { }); // Initialize servers namespace and function builder with injected functions - this.servers = new ServersNamespace( - this.#performCall.bind(this), - this.#getToolsByServer.bind(this), - this.#generatePromptInternal.bind(this), - this.#getPromptsByServer.bind(this), - ); + this.servers = new ServersNamespace({ + performCall: this.#performCall.bind(this), + getTools: this.#getToolsByServer.bind(this), + generatePrompt: this.#generatePromptInternal.bind(this), + getPrompts: this.#getPromptsByServer.bind(this), + }); this.#functionBuilder = new FunctionBuilder(this.#performCall.bind(this)); } @@ -299,18 +299,8 @@ export class McpdClient { async getToolSchemas(options?: { servers?: string[] }): Promise { const { servers } = options || {}; - // Determine which servers to query - const serverNames = - servers && servers.length > 0 ? servers : await this.listServers(); - - // Get health status for all servers (single API call) - const healthMap = await this.getServerHealth(); - - // Filter to only healthy servers - const healthyServers = serverNames.filter((name) => { - const health = healthMap[name]; - return health && HealthStatusHelpers.isHealthy(health.status); - }); + // 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( @@ -384,18 +374,8 @@ export class McpdClient { async getPrompts(options?: { servers?: string[] }): Promise { const { servers } = options || {}; - // Determine which servers to query. - const serverNames = - servers && servers.length > 0 ? servers : await this.listServers(); - - // Get health status for all servers. - const healthMap = await this.getServerHealth(); - - // Filter to only healthy servers. - const healthyServers = serverNames.filter((name) => { - const health = healthMap[name]; - return health && HealthStatusHelpers.isHealthy(health.status); - }); + // 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( @@ -406,18 +386,18 @@ export class McpdClient { ); // Process results and transform prompt names. -const allPrompts: Prompt[] = results.flatMap(result => { - if (result.status === "fulfilled") { - const { serverName, prompts } = result.value; - return prompts.map(prompt => ({ - ...prompt, - name: `${serverName}__${prompt.name}`, - })); - } else { - console.warn(`Failed to get prompts for server:`, result.reason); - return []; // Return an empty array for rejected promises - } -}); + const allPrompts: Prompt[] = results.flatMap((result) => { + if (result.status === "fulfilled") { + const { serverName, prompts } = result.value; + return prompts.map((prompt) => ({ + ...prompt, + name: `${serverName}__${prompt.name}`, + })); + } else { + console.warn(`Failed to get prompts for server:`, result.reason); + return []; // Return an empty array for rejected promises + } + }); return allPrompts; } @@ -531,12 +511,11 @@ const allPrompts: Prompt[] = results.flatMap(result => { serverName: string, cursor?: string, ): Promise { - // Check server health first. - await this.#ensureServerHealthy(serverName); - - const path = API_PATHS.SERVER_PROMPTS(serverName, cursor); - try { + // Check server health first. + await this.#ensureServerHealthy(serverName); + + const path = API_PATHS.SERVER_PROMPTS(serverName, cursor); const response = await this.#request(path); return response.prompts || []; } catch (error) { @@ -713,6 +692,29 @@ const allPrompts: Prompt[] = results.flatMap(result => { } } + /** + * Get list of healthy servers from optional server names. + * + * This helper fetches server names (if not provided) and filters to only healthy servers. + * Used by getToolSchemas(), getPrompts(), and agentTools() to avoid timeouts on failed servers. + * + * @param servers - Optional array of server names. If not provided, fetches all servers. + * @returns Array of healthy server names + * @internal + */ + async #getHealthyServers(servers?: string[]): Promise { + // Get server names if not provided. + const serverNames = + servers && servers.length > 0 ? servers : await this.listServers(); + + // Get health status and filter to healthy servers. + const healthMap = await this.getServerHealth(); + return serverNames.filter((name) => { + const health = healthMap[name]; + return health && HealthStatusHelpers.isHealthy(health.status); + }); + } + /** * Internal method to perform a tool call on a server. * @@ -811,18 +813,8 @@ const allPrompts: Prompt[] = results.flatMap(result => { * @internal */ async agentTools(servers?: string[]): Promise { - // Determine which servers to query - const serverNames = - servers && servers.length > 0 ? servers : await this.listServers(); - - // Get health status for all servers (single API call) - const healthMap = await this.getServerHealth(); - - // Filter to only healthy servers - const healthyServers = serverNames.filter((name) => { - const health = healthMap[name]; - return health && HealthStatusHelpers.isHealthy(health.status); - }); + // 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( diff --git a/src/dynamicCaller.ts b/src/dynamicCaller.ts index ff8813e..75bd9dc 100644 --- a/src/dynamicCaller.ts +++ b/src/dynamicCaller.ts @@ -54,17 +54,23 @@ export class ServersNamespace { /** * Initialize the ServersNamespace with injected functions. * - * @param performCall - Function to execute tool calls - * @param getTools - Function to get tool schemas - * @param generatePrompt - Function to generate prompts - * @param getPrompts - Function to get prompt schemas + * @param options - Configuration object + * @param options.performCall - Function to execute tool calls + * @param options.getTools - Function to get tool schemas + * @param options.generatePrompt - Function to generate prompts + * @param options.getPrompts - Function to get prompt schemas */ - constructor( - performCall: PerformCallFn, - getTools: GetToolsFn, - generatePrompt: GeneratePromptFn, - getPrompts: GetPromptsFn, - ) { + constructor({ + performCall, + getTools, + generatePrompt, + getPrompts, + }: { + performCall: PerformCallFn; + getTools: GetToolsFn; + generatePrompt: GeneratePromptFn; + getPrompts: GetPromptsFn; + }) { this.#performCall = performCall; this.#getTools = getTools; this.#generatePrompt = generatePrompt; @@ -177,8 +183,13 @@ export class Server { * * The tool name 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 toolName - The exact name of the tool to check - * @returns True if the tool exists, false otherwise + * @returns True if the tool exists, false otherwise (including on errors) * * @example * ```typescript @@ -192,6 +203,7 @@ export class Server { const tools = await this.#getTools(this.#serverName); return tools.some((t) => t.name === toolName); } catch { + // Return false on any error to provide a safe boolean predicate. return false; } } @@ -242,6 +254,10 @@ export class Server { /** * Get all prompts 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 prompt schemas * @throws {ServerNotFoundError} If the server doesn't exist * @throws {ServerUnhealthyError} If the server is unhealthy @@ -263,8 +279,13 @@ export class Server { * * The prompt name 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 promptName - The exact name of the prompt to check - * @returns True if the prompt exists, false otherwise + * @returns True if the prompt exists, false otherwise (including on errors) * * @example * ```typescript @@ -278,6 +299,7 @@ export class Server { const prompts = await this.#getPrompts(this.#serverName); return prompts.some((p) => p.name === promptName); } catch { + // Return false on any error to provide a safe boolean predicate. return false; } } @@ -455,8 +477,7 @@ export class PromptsNamespace { const promptName = prop; // Check if the prompt exists (exact match only). - const prompts = await target.#getPrompts(target.#serverName); - const prompt = prompts.find((p) => p.name === promptName); + const prompt = await target.#getPromptByName(promptName); if (!prompt) { throw new ToolNotFoundError( @@ -473,4 +494,16 @@ export class PromptsNamespace { }, }); } + + /** + * Helper method to find a prompt by name on this server. + * + * @param promptName - The exact name of the prompt to find + * @returns The prompt if found, undefined otherwise + * @internal + */ + async #getPromptByName(promptName: string): Promise { + const prompts = await this.#getPrompts(this.#serverName); + return prompts.find((p) => p.name === promptName); + } } From 77bfc5d9e3cba862b5ccdca10b81cc8befe10cfe Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Fri, 17 Oct 2025 15:52:47 +0100 Subject: [PATCH 4/6] add 'check' to handle all formatting/linting/testing etc. --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 9e7f545..529a292 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "typecheck": "tsc --noEmit", "typecheck:tests": "tsc --noEmit --project tsconfig.test.json", "typecheck:all": "npm run typecheck && npm run typecheck:tests", + "check": "npm run format && npm run lint:fix && npm run typecheck:all && npm test && npm run build", "prepublishOnly": "npm run build" }, "keywords": [ From 8d908d987c038e59c91a88066a3fdf729aa40322 Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Fri, 17 Oct 2025 15:54:32 +0100 Subject: [PATCH 5/6] Fix comments --- src/client.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/client.ts b/src/client.ts index 58491d2..998ff81 100644 --- a/src/client.ts +++ b/src/client.ts @@ -535,12 +535,9 @@ export class McpdClient { /** * Internal method to generate a prompt on a server. * - * ⚠️ This method is truly private and cannot be accessed by SDK consumers. - * Use the fluent API instead: `client.servers.foo.prompts.bar(args)` - * * This method is used internally by: * - PromptsNamespace (via dependency injection) - * - Server.getPrompt() (via dependency injection) + * - Server.generatePrompt() (via dependency injection) * * @param serverName - The name of the server * @param promptName - The exact name of the prompt @@ -718,11 +715,8 @@ export class McpdClient { /** * Internal method to perform a tool call on a server. * - * ⚠️ This method is truly private and cannot be accessed by SDK consumers. - * Use the fluent API instead: `client.servers.foo.tools.bar(args)` - * * This method is used internally by: - * - ToolsProxy (via dependency injection) + * - ToolsNamespace (via dependency injection) * - FunctionBuilder (via dependency injection) * * @param serverName - The name of the server From 2e656fa5b04736465e90eca9b1bb3b9e5f7c8e8b Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Fri, 17 Oct 2025 15:55:49 +0100 Subject: [PATCH 6/6] Update README --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 4e6a49f..03a7a12 100644 --- a/README.md +++ b/README.md @@ -504,6 +504,9 @@ npm run test:coverage ### Linting and Formatting ```bash +# Run all checks (format, lint, typecheck, test, build) +npm run check + # Run linter npm run lint