diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3c566e7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,100 @@ +name: Release + +on: + push: + tags: + - "v*.*.*" + - "v*.*.*-alpha.*" + - "v*.*.*-beta.*" + +permissions: + contents: read + id-token: write # Required for npm provenance + +env: + NODE_VERSION: "22.x" + +jobs: + verify-version: + name: Verify version + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Extract version from tag + id: tag + run: | + TAG=${GITHUB_REF#refs/tags/v} + echo "version=$TAG" >> $GITHUB_OUTPUT + echo "Tagged version: $TAG" + + - name: Read package.json version + id: package + run: | + VERSION=$(node -p "require('./package.json').version") + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Package version: $VERSION" + + - name: Compare versions + run: | + if [ "${{ steps.tag.outputs.version }}" != "${{ steps.package.outputs.version }}" ]; then + echo "ERROR: Tag version (${{ steps.tag.outputs.version }}) does not match package.json version (${{ steps.package.outputs.version }})" + exit 1 + fi + echo "Versions match: ${{ steps.tag.outputs.version }}" + + publish: + name: Publish to npm + runs-on: ubuntu-latest + needs: verify-version + environment: + name: npm + url: https://www.npmjs.com/package/@ampersend_ai/fastmcp + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + package_json_file: package.json + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + registry-url: "https://registry.npmjs.org" + cache: "pnpm" + cache-dependency-path: pnpm-lock.yaml + + - name: Update npm + run: npm install -g npm@latest + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build package + run: pnpm build + + - name: Determine publish tag + id: publish-tag + run: | + VERSION="${GITHUB_REF#refs/tags/v}" + if [[ "$VERSION" == *"-alpha."* ]]; then + echo "tag=alpha" >> $GITHUB_OUTPUT + echo "Publishing with tag: alpha" + elif [[ "$VERSION" == *"-beta."* ]]; then + echo "tag=beta" >> $GITHUB_OUTPUT + echo "Publishing with tag: beta" + else + echo "tag=latest" >> $GITHUB_OUTPUT + echo "Publishing with tag: latest" + fi + + - name: Publish to npm + run: npm publish --access public --tag ${{ steps.publish-tag.outputs.tag }} + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/README.md b/README.md index ca6aac9..b2fda06 100644 --- a/README.md +++ b/README.md @@ -771,7 +771,53 @@ The `log` object has the following methods: #### Errors -The errors that are meant to be shown to the user should be thrown as `UserError` instances: +FastMCP supports two ways to handle errors in tool execution: + +##### MCP Errors (Recommended) + +For standards-compliant error handling, throw `McpError` with appropriate error codes: + +```js +import { ErrorCode, McpError } from "fastmcp"; + +server.addTool({ + name: "download", + description: "Download a file", + parameters: z.object({ + url: z.string(), + }), + execute: async (args) => { + if (args.url.startsWith("https://example.com")) { + // Throw MCP error with InvalidParams code + throw new McpError(ErrorCode.InvalidParams, "This URL is not allowed"); + } + + // Throw MCP error with custom data + if (!urlExists(args.url)) { + throw new McpError(ErrorCode.InvalidRequest, "Resource not found", { + url: args.url, + statusCode: 404, + }); + } + + return "done"; + }, +}); +``` + +**Available Error Codes:** + +- `ErrorCode.InvalidParams` - Invalid parameters provided +- `ErrorCode.InvalidRequest` - Invalid request +- `ErrorCode.InternalError` - Internal server error +- `ErrorCode.MethodNotFound` - Method/resource not found +- And other standard JSON-RPC error codes + +When a tool throws `McpError`, it's propagated through the MCP protocol as a proper JSON-RPC error, allowing clients to handle different error types appropriately. + +##### User Errors (Legacy) + +For backward compatibility, you can still use `UserError` for simple error messages: ```js import { UserError } from "fastmcp"; @@ -792,6 +838,8 @@ server.addTool({ }); ``` +`UserError` errors are converted to tool responses with `isError: true` and are displayed to the user as text content. + #### Progress Tools can report progress by calling `reportProgress` in the context object: @@ -1316,9 +1364,9 @@ In this example, only clients authenticating with the `admin` role will be able FastMCP includes built-in support for OAuth discovery endpoints, supporting both **MCP Specification 2025-03-26** and **MCP Specification 2025-06-18** for OAuth integration. This makes it easy to integrate with OAuth authorization flows by providing standard discovery endpoints that comply with RFC 8414 (OAuth 2.0 Authorization Server Metadata) and RFC 9470 (OAuth 2.0 Protected Resource Metadata): ```ts +import fastJwt from "fast-jwt"; import { FastMCP } from "fastmcp"; import { buildGetJwks } from "get-jwks"; -import fastJwt from "fast-jwt"; const server = new FastMCP({ name: "My Server", @@ -1420,9 +1468,10 @@ For JWT token validation, you can use libraries like [`get-jwks`](https://github If you are exposing your MCP server via HTTP, you may wish to allow clients to supply sensitive keys via headers, which can then be passed along to APIs that your tools interact with, allowing each client to supply their own API keys. This can be done by capturing the HTTP headers in the `authenticate` section and storing them in the session to be referenced by the tools later. ```ts -import { FastMCP } from "fastmcp"; import { IncomingHttpHeaders } from "http"; +import { FastMCP } from "fastmcp"; + // Define the session data type interface SessionData { headers: IncomingHttpHeaders; @@ -1470,8 +1519,8 @@ server.start({ A client that would connect to this may look something like this: ```ts -import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const transport = new StreamableHTTPClientTransport( new URL(`http://localhost:8080/mcp`), diff --git a/jsr.json b/jsr.json index 3ab8734..e0104bb 100644 --- a/jsr.json +++ b/jsr.json @@ -2,6 +2,6 @@ "exports": "./src/FastMCP.ts", "include": ["src/FastMCP.ts", "src/bin/fastmcp.ts"], "license": "MIT", - "name": "@punkpeye/fastmcp", + "name": "@ampersend_ai/fastmcp", "version": "1.0.0" } diff --git a/package.json b/package.json index 852ee00..e50c2cd 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "fastmcp", + "name": "@ampersend_ai/fastmcp", "version": "1.0.0", "main": "dist/FastMCP.js", "scripts": { @@ -22,7 +22,7 @@ "module": "dist/FastMCP.js", "types": "dist/FastMCP.d.ts", "dependencies": { - "@modelcontextprotocol/sdk": "^1.17.2", + "@modelcontextprotocol/sdk": "npm:@ampersend_ai/modelcontextprotocol-sdk@^1.20.1", "@standard-schema/spec": "^1.0.0", "execa": "^9.6.0", "file-type": "^21.0.0", @@ -37,7 +37,7 @@ "zod-to-json-schema": "^3.24.6" }, "repository": { - "url": "https://github.com/punkpeye/fastmcp" + "url": "https://github.com/edgeandnode/fastmcp" }, "homepage": "https://glama.ai/mcp", "release": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b662093..d67aea1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@modelcontextprotocol/sdk': - specifier: ^1.17.2 - version: 1.17.2 + specifier: npm:@ampersend_ai/modelcontextprotocol-sdk@^1.20.1 + version: '@ampersend_ai/modelcontextprotocol-sdk@1.20.1' '@standard-schema/spec': specifier: ^1.0.0 version: 1.0.0 @@ -126,6 +126,10 @@ importers: packages: + '@ampersend_ai/modelcontextprotocol-sdk@1.20.1': + resolution: {integrity: sha512-u28xfWkgMyYsFW9jyJWuU8BndOFGJVPGRXu6g7t1iuOOTDA76lBAB5a6cbWzL4Y852gHYNyit3qTSL4I3EHCuw==} + engines: {node: '>=18'} + '@ark/schema@0.46.0': resolution: {integrity: sha512-c2UQdKgP2eqqDArfBqQIJppxJHvNNXuQPeuSPlDML4rjw+f1cu0qAlzOG4b8ujgm9ctIDWwhpyw6gjG5ledIVQ==} @@ -3711,6 +3715,23 @@ packages: snapshots: + '@ampersend_ai/modelcontextprotocol-sdk@1.20.1': + dependencies: + ajv: 6.12.6 + content-type: 1.0.5 + cors: 2.8.5 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.3 + express: 5.1.0 + express-rate-limit: 7.5.1(express@5.1.0) + pkce-challenge: 5.0.0 + raw-body: 3.0.0 + zod: 3.25.76 + zod-to-json-schema: 3.24.6(zod@3.25.76) + transitivePeerDependencies: + - supports-color + '@ark/schema@0.46.0': dependencies: '@ark/util': 0.46.0 diff --git a/src/FastMCP.test.ts b/src/FastMCP.test.ts index aa8ea3b..267c1eb 100644 --- a/src/FastMCP.test.ts +++ b/src/FastMCP.test.ts @@ -486,6 +486,132 @@ test("handles UserError errors with extras", async () => { }); }); +test("tool can throw McpError with InvalidParams error code", async () => { + await runWithTestServer({ + run: async ({ client }) => { + try { + await client.callTool({ + arguments: { value: "invalid" }, + name: "validate", + }); + throw new Error("Expected error to be thrown"); + } catch (error) { + expect(error).toBeInstanceOf(McpError); + + // @ts-expect-error - we know that error is an McpError + expect(error.code).toBe(ErrorCode.InvalidParams); + + // @ts-expect-error - we know that error is an McpError + expect(error.message).toContain("Invalid value provided"); + } + }, + server: async () => { + const server = new FastMCP({ + name: "Test", + version: "1.0.0", + }); + + server.addTool({ + description: "Validate input", + execute: async () => { + throw new McpError(ErrorCode.InvalidParams, "Invalid value provided"); + }, + name: "validate", + parameters: z.object({ value: z.string() }), + }); + + return server; + }, + }); +}); + +test("tool can throw McpError with InternalError error code", async () => { + await runWithTestServer({ + run: async ({ client }) => { + try { + await client.callTool({ + arguments: { value: "test" }, + name: "process", + }); + throw new Error("Expected error to be thrown"); + } catch (error) { + expect(error).toBeInstanceOf(McpError); + + // @ts-expect-error - we know that error is an McpError + expect(error.code).toBe(ErrorCode.InternalError); + + // @ts-expect-error - we know that error is an McpError + expect(error.message).toContain("Internal processing error"); + } + }, + server: async () => { + const server = new FastMCP({ + name: "Test", + version: "1.0.0", + }); + + server.addTool({ + description: "Process data", + execute: async () => { + throw new McpError( + ErrorCode.InternalError, + "Internal processing error", + ); + }, + name: "process", + parameters: z.object({ value: z.string() }), + }); + + return server; + }, + }); +}); + +test("tool can throw McpError with custom data", async () => { + await runWithTestServer({ + run: async ({ client }) => { + try { + await client.callTool({ + arguments: { id: "123" }, + name: "find", + }); + throw new Error("Expected error to be thrown"); + } catch (error) { + expect(error).toBeInstanceOf(McpError); + + // @ts-expect-error - we know that error is an McpError + expect(error.code).toBe(ErrorCode.InvalidRequest); + + // @ts-expect-error - we know that error is an McpError + expect(error.message).toContain("Resource not found"); + + // Note: Custom data may not be preserved through the MCP SDK transport layer + // The important part is that the error code and message are correct + } + }, + server: async () => { + const server = new FastMCP({ + name: "Test", + version: "1.0.0", + }); + + server.addTool({ + description: "Find resource", + execute: async (args) => { + throw new McpError(ErrorCode.InvalidRequest, "Resource not found", { + available: ["456", "789"], + id: args.id, + }); + }, + name: "find", + parameters: z.object({ id: z.string() }), + }); + + return server; + }, + }); +}); + test("calling an unknown tool throws McpError with MethodNotFound code", async () => { await runWithTestServer({ run: async ({ client }) => { @@ -570,6 +696,72 @@ test("tracks tool progress", async () => { }); }); +test("provides requestMetadata to tool context", async () => { + let capturedMetadata: Record | undefined = undefined; + const metadata = { foo: "bar" }; + + await runWithTestServer({ + run: async ({ client }) => { + await client.callTool({ + _meta: metadata, + name: "metadata-test", + }); + + expect(capturedMetadata).toBeDefined(); + expect(capturedMetadata).toEqual(metadata); + }, + server: async () => { + const server = new FastMCP({ + name: "Test", + version: "1.0.0", + }); + + server.addTool({ + execute: async (_args, context) => { + capturedMetadata = context.requestMetadata; + return "success"; + }, + name: "metadata-test", + }); + + return server; + }, + }); +}); + +test("allows tools to return _meta in CallToolResult", async () => { + const expectedMeta = { customField: "customValue", timestamp: 1234567890 }; + + await runWithTestServer({ + run: async ({ client }) => { + const result = await client.callTool({ + name: "meta-result-test", + }); + + expect(result._meta).toBeDefined(); + expect(result._meta).toEqual(expectedMeta); + }, + server: async () => { + const server = new FastMCP({ + name: "Test", + version: "1.0.0", + }); + + server.addTool({ + execute: async () => { + return { + _meta: expectedMeta, + content: [{ text: "success", type: "text" }], + }; + }, + name: "meta-result-test", + }); + + return server; + }, + }); +}); + test( "reports multiple progress updates without buffering", { @@ -667,26 +859,24 @@ test("sets logging levels", async () => { test("handles tool timeout", async () => { await runWithTestServer({ run: async ({ client }) => { - const result = await client.callTool({ - arguments: { - a: 1500, - b: 2, - }, - name: "add", - }); - - expect(result.isError).toBe(true); - - const result_typed = result as ContentResult; - - expect(Array.isArray(result_typed.content)).toBe(true); - expect(result_typed.content.length).toBe(1); + try { + await client.callTool({ + arguments: { + a: 1500, + b: 2, + }, + name: "add", + }); + throw new Error("Expected timeout error to be thrown"); + } catch (error) { + expect(error).toBeInstanceOf(McpError); - const firstItem = result_typed.content[0] as TextContent; + // @ts-expect-error - we know that error is an McpError + expect(error.code).toBe(ErrorCode.InternalError); - expect(firstItem.type).toBe("text"); - expect(firstItem.text).toBeDefined(); - expect(firstItem.text).toContain("timed out"); + // @ts-expect-error - we know that error is an McpError + expect(error.message).toContain("timed out"); + } }, server: async () => { const server = new FastMCP({ diff --git a/src/FastMCP.ts b/src/FastMCP.ts index 5e3f7bc..ebbefd0 100644 --- a/src/FastMCP.ts +++ b/src/FastMCP.ts @@ -19,6 +19,7 @@ import { ListToolsRequestSchema, McpError, ReadResourceRequestSchema, + RequestMeta, ResourceLink, Root, RootsListChangedNotificationSchema, @@ -80,9 +81,7 @@ export const imageContent = async ( rawData = Buffer.from(await response.arrayBuffer()); } catch (error) { throw new Error( - `Failed to fetch image from URL (${input.url}): ${ - error instanceof Error ? error.message : String(error) - }`, + `Failed to fetch image from URL (${input.url}): ${error instanceof Error ? error.message : String(error)}`, ); } } else if ("path" in input) { @@ -90,9 +89,7 @@ export const imageContent = async ( rawData = await readFile(input.path); } catch (error) { throw new Error( - `Failed to read image from path (${input.path}): ${ - error instanceof Error ? error.message : String(error) - }`, + `Failed to read image from path (${input.path}): ${error instanceof Error ? error.message : String(error)}`, ); } } else if ("buffer" in input) { @@ -108,9 +105,7 @@ export const imageContent = async ( if (!mimeType || !mimeType.mime.startsWith("image/")) { console.warn( - `Warning: Content may not be a valid image. Detected MIME: ${ - mimeType?.mime || "unknown" - }`, + `Warning: Content may not be a valid image. Detected MIME: ${mimeType?.mime || "unknown"}`, ); } @@ -149,9 +144,7 @@ export const audioContent = async ( rawData = Buffer.from(await response.arrayBuffer()); } catch (error) { throw new Error( - `Failed to fetch audio from URL (${input.url}): ${ - error instanceof Error ? error.message : String(error) - }`, + `Failed to fetch audio from URL (${input.url}): ${error instanceof Error ? error.message : String(error)}`, ); } } else if ("path" in input) { @@ -159,9 +152,7 @@ export const audioContent = async ( rawData = await readFile(input.path); } catch (error) { throw new Error( - `Failed to read audio from path (${input.path}): ${ - error instanceof Error ? error.message : String(error) - }`, + `Failed to read audio from path (${input.path}): ${error instanceof Error ? error.message : String(error)}`, ); } } else if ("buffer" in input) { @@ -177,9 +168,7 @@ export const audioContent = async ( if (!mimeType || !mimeType.mime.startsWith("audio/")) { console.warn( - `Warning: Content may not be a valid audio file. Detected MIME: ${ - mimeType?.mime || "unknown" - }`, + `Warning: Content may not be a valid audio file. Detected MIME: ${mimeType?.mime || "unknown"}`, ); } @@ -215,6 +204,7 @@ type Context = { * Available for all transports when the client provides it. */ requestId?: string; + requestMetadata?: RequestMeta; session: T | undefined; /** * Session ID from the Mcp-Session-Id header. @@ -262,6 +252,26 @@ abstract class FastMCPError extends Error { } } +/** + * Custom MCP error with a marker property to enable robust error detection + * across module boundaries. + * + * This class extends McpError and adds a `__isMcpError` marker property. + * This allows error detection to work even when instanceof fails due to + * module instances being loaded multiple times (e.g., with tsx, different + * bundlers, or ESM/CommonJS mixing). + * + * Use this instead of McpError when you need the error to be re-thrown + * by FastMCP's error handling rather than wrapped in a result. + */ +export class CustomMcpError extends McpError { + readonly __isMcpError = true; + + constructor(code: number, message: string, data?: unknown) { + super(code, message, data); + } +} + export class UnexpectedStateError extends FastMCPError { public extras?: Extras; @@ -277,6 +287,23 @@ export class UnexpectedStateError extends FastMCPError { */ export class UserError extends UnexpectedStateError {} +/** + * Type guard to check if an error should be re-thrown as an MCP error. + * Works across module boundaries by checking both instanceof and marker property. + * + * @param error - The error to check + * @returns true if error is an McpError or has the __isMcpError marker + */ +export function isMcpErrorLike(error: unknown): error is McpError { + return ( + error instanceof McpError || + (typeof error === "object" && + error !== null && + "__isMcpError" in error && + (error as { __isMcpError?: boolean }).__isMcpError === true) + ); +} + const TextContentZodSchema = z .object({ /** @@ -371,12 +398,14 @@ const ContentZodSchema = z.discriminatedUnion("type", [ ]) satisfies z.ZodType; type ContentResult = { + _meta?: Record; content: Content[]; isError?: boolean; }; const ContentResultZodSchema = z .object({ + _meta: z.record(z.unknown()).optional(), content: ContentZodSchema.array(), isError: z.boolean().optional(), }) @@ -1150,9 +1179,7 @@ export class FastMCPSession< ); } else { this.#logger.error( - `[FastMCP error] received error listing roots.\n\n${ - e instanceof Error ? e.stack : JSON.stringify(e) - }`, + `[FastMCP error] received error listing roots.\n\n${e instanceof Error ? e.stack : JSON.stringify(e)}`, ); } } @@ -1819,7 +1846,6 @@ export class FastMCPSession< ); } }; - const executeToolPromise = tool.execute(args, { client: { version: this.#server.getClientVersion(), @@ -1830,6 +1856,7 @@ export class FastMCPSession< typeof request.params?._meta?.requestId === "string" ? request.params._meta.requestId : undefined, + requestMetadata: request.params._meta, session: this.#auth, sessionId: this.#sessionId, streamContent, @@ -1842,7 +1869,8 @@ export class FastMCPSession< new Promise((_, reject) => { const timeoutId = setTimeout(() => { reject( - new UserError( + new McpError( + ErrorCode.InternalError, `Tool '${request.params.name}' timed out after ${tool.timeoutMs}ms. Consider increasing timeoutMs or optimizing the tool implementation.`, ), ); @@ -1883,6 +1911,12 @@ export class FastMCPSession< result = ContentResultZodSchema.parse(maybeStringResult); } } catch (error) { + // Re-throw McpError to let the MCP SDK handle it as a proper JSON-RPC error + // Use type guard to handle instanceof failures across module boundaries + if (isMcpErrorLike(error)) { + throw error; + } + if (error instanceof UserError) { return { content: [{ text: error.message, type: "text" }], @@ -2520,6 +2554,8 @@ export class FastMCP< } } +export { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js"; + export type { AudioContent, Content, @@ -2534,6 +2570,7 @@ export type { Progress, Prompt, PromptArgument, + RequestMeta, Resource, ResourceContent, ResourceLink, diff --git a/src/bin/fastmcp.ts b/src/bin/fastmcp.ts index 537330c..769f20e 100644 --- a/src/bin/fastmcp.ts +++ b/src/bin/fastmcp.ts @@ -1,5 +1,4 @@ #!/usr/bin/env node - import { execa } from "execa"; import yargs from "yargs"; import { hideBin } from "yargs/helpers";