├── .github └── workflows │ ├── feature.yaml │ └── main.yaml ├── .gitignore ├── LICENSE ├── README.md ├── eslint.config.ts ├── jsr.json ├── package.json ├── pnpm-lock.yaml ├── src ├── FastMCP.test.ts ├── FastMCP.ts ├── bin │ └── fastmcp.ts └── examples │ └── addition.ts ├── tsconfig.json └── vitest.config.js /.github/workflows/feature.yaml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | on: 3 | pull_request: 4 | branches: 5 | - main 6 | types: 7 | - opened 8 | - synchronize 9 | - reopened 10 | - ready_for_review 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | name: Test 15 | strategy: 16 | fail-fast: true 17 | matrix: 18 | node: 19 | - 22 20 | steps: 21 | - name: Checkout repository 22 | uses: actions/checkout@v4 23 | with: 24 | fetch-depth: 0 25 | - uses: pnpm/action-setup@v4 26 | with: 27 | version: 9 28 | - name: Setup NodeJS ${{ matrix.node }} 29 | uses: actions/setup-node@v4 30 | with: 31 | node-version: ${{ matrix.node }} 32 | cache: "pnpm" 33 | cache-dependency-path: "**/pnpm-lock.yaml" 34 | - name: Install dependencies 35 | run: pnpm install 36 | - name: Run lint 37 | run: pnpm lint 38 | - name: Run tests 39 | run: pnpm test 40 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | test: 8 | environment: release 9 | name: Test 10 | strategy: 11 | fail-fast: true 12 | matrix: 13 | node: 14 | - 22 15 | runs-on: ubuntu-latest 16 | permissions: 17 | contents: write 18 | id-token: write 19 | steps: 20 | - name: setup repository 21 | uses: actions/checkout@v4 22 | with: 23 | fetch-depth: 0 24 | - uses: pnpm/action-setup@v4 25 | with: 26 | version: 9 27 | - name: setup node.js 28 | uses: actions/setup-node@v4 29 | with: 30 | cache: "pnpm" 31 | node-version: ${{ matrix.node }} 32 | - name: Setup NodeJS ${{ matrix.node }} 33 | uses: actions/setup-node@v4 34 | with: 35 | node-version: ${{ matrix.node }} 36 | cache: "pnpm" 37 | cache-dependency-path: "**/pnpm-lock.yaml" 38 | - name: Install dependencies 39 | run: pnpm install 40 | - name: Run lint 41 | run: pnpm lint 42 | - name: Run tests 43 | run: pnpm test 44 | - name: Build 45 | run: pnpm build 46 | - name: Release 47 | run: pnpm semantic-release 48 | env: 49 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 50 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | ===================== 3 | 4 | Copyright © 2024 Frank Fiegel (frank@glama.ai) 5 | 6 | Permission is hereby granted, free of charge, to any person 7 | obtaining a copy of this software and associated documentation 8 | files (the “Software”), to deal in the Software without 9 | restriction, including without limitation the rights to use, 10 | copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the 12 | Software is furnished to do so, subject to the following 13 | conditions: 14 | 15 | The above copyright notice and this permission notice shall be 16 | included in all copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, 19 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 20 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 21 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 22 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 23 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 24 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 25 | OTHER DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FastMCP 2 | 3 | A TypeScript framework for building [MCP](https://glama.ai/mcp) servers capable of handling client sessions. 4 | 5 | > [!NOTE] 6 | > 7 | > For a Python implementation, see [FastMCP](https://github.com/jlowin/fastmcp). 8 | 9 | ## Features 10 | 11 | - Simple Tool, Resource, Prompt definition 12 | - [Authentication](#authentication) 13 | - [Sessions](#sessions) 14 | - [Image content](#returning-an-image) 15 | - [Audio content](#returning-an-audio) 16 | - [Embedded](#embedded-resources) 17 | - [Logging](#logging) 18 | - [Error handling](#errors) 19 | - [HTTP Streaming](#http-streaming) (with SSE compatibility) 20 | - CORS (enabled by default) 21 | - [Progress notifications](#progress) 22 | - [Streaming output](#streaming-output) 23 | - [Typed server events](#typed-server-events) 24 | - [Prompt argument auto-completion](#prompt-argument-auto-completion) 25 | - [Sampling](#requestsampling) 26 | - [Configurable ping behavior](#configurable-ping-behavior) 27 | - [Health-check endpoint](#health-check-endpoint) 28 | - [Roots](#roots-management) 29 | - CLI for [testing](#test-with-mcp-cli) and [debugging](#inspect-with-mcp-inspector) 30 | 31 | ## Installation 32 | 33 | ```bash 34 | npm install fastmcp 35 | ``` 36 | 37 | ## Quickstart 38 | 39 | > [!NOTE] 40 | > 41 | > There are many real-world examples of using FastMCP in the wild. See the [Showcase](#showcase) for examples. 42 | 43 | ```ts 44 | import { FastMCP } from "fastmcp"; 45 | import { z } from "zod"; // Or any validation library that supports Standard Schema 46 | 47 | const server = new FastMCP({ 48 | name: "My Server", 49 | version: "1.0.0", 50 | }); 51 | 52 | server.addTool({ 53 | name: "add", 54 | description: "Add two numbers", 55 | parameters: z.object({ 56 | a: z.number(), 57 | b: z.number(), 58 | }), 59 | execute: async (args) => { 60 | return String(args.a + args.b); 61 | }, 62 | }); 63 | 64 | server.start({ 65 | transportType: "stdio", 66 | }); 67 | ``` 68 | 69 | _That's it!_ You have a working MCP server. 70 | 71 | You can test the server in terminal with: 72 | 73 | ```bash 74 | git clone https://github.com/punkpeye/fastmcp.git 75 | cd fastmcp 76 | 77 | pnpm install 78 | pnpm build 79 | 80 | # Test the addition server example using CLI: 81 | npx fastmcp dev src/examples/addition.ts 82 | # Test the addition server example using MCP Inspector: 83 | npx fastmcp inspect src/examples/addition.ts 84 | ``` 85 | 86 | If you are looking for a boilerplate repository to build your own MCP server, check out [fastmcp-boilerplate](https://github.com/punkpeye/fastmcp-boilerplate). 87 | 88 | ### Remote Server Options 89 | 90 | FastMCP supports multiple transport options for remote communication, allowing an MCP hosted on a remote machine to be accessed over the network. 91 | 92 | #### HTTP Streaming 93 | 94 | [HTTP streaming](https://www.cloudflare.com/learning/video/what-is-http-live-streaming/) provides a more efficient alternative to SSE in environments that support it, with potentially better performance for larger payloads. 95 | 96 | You can run the server with HTTP streaming support: 97 | 98 | ```ts 99 | server.start({ 100 | transportType: "httpStream", 101 | httpStream: { 102 | port: 8080, 103 | }, 104 | }); 105 | ``` 106 | 107 | This will start the server and listen for HTTP streaming connections on `http://localhost:8080/stream`. 108 | 109 | You can connect to these servers using the appropriate client transport. 110 | 111 | For HTTP streaming connections: 112 | 113 | ```ts 114 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; 115 | 116 | const client = new Client( 117 | { 118 | name: "example-client", 119 | version: "1.0.0", 120 | }, 121 | { 122 | capabilities: {}, 123 | }, 124 | ); 125 | 126 | const transport = new StreamableHTTPClientTransport( 127 | new URL(`http://localhost:8080/stream`), 128 | ); 129 | 130 | await client.connect(transport); 131 | ``` 132 | 133 | For SSE connections: 134 | 135 | ```ts 136 | import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; 137 | 138 | const client = new Client( 139 | { 140 | name: "example-client", 141 | version: "1.0.0", 142 | }, 143 | { 144 | capabilities: {}, 145 | }, 146 | ); 147 | 148 | const transport = new SSEClientTransport(new URL(`http://localhost:8080/sse`)); 149 | 150 | await client.connect(transport); 151 | ``` 152 | 153 | ## Core Concepts 154 | 155 | ### Tools 156 | 157 | [Tools](https://modelcontextprotocol.io/docs/concepts/tools) in MCP allow servers to expose executable functions that can be invoked by clients and used by LLMs to perform actions. 158 | 159 | FastMCP uses the [Standard Schema](https://standardschema.dev) specification for defining tool parameters. This allows you to use your preferred schema validation library (like Zod, ArkType, or Valibot) as long as it implements the spec. 160 | 161 | **Zod Example:** 162 | 163 | ```typescript 164 | import { z } from "zod"; 165 | 166 | server.addTool({ 167 | name: "fetch-zod", 168 | description: "Fetch the content of a url (using Zod)", 169 | parameters: z.object({ 170 | url: z.string(), 171 | }), 172 | execute: async (args) => { 173 | return await fetchWebpageContent(args.url); 174 | }, 175 | }); 176 | ``` 177 | 178 | **ArkType Example:** 179 | 180 | ```typescript 181 | import { type } from "arktype"; 182 | 183 | server.addTool({ 184 | name: "fetch-arktype", 185 | description: "Fetch the content of a url (using ArkType)", 186 | parameters: type({ 187 | url: "string", 188 | }), 189 | execute: async (args) => { 190 | return await fetchWebpageContent(args.url); 191 | }, 192 | }); 193 | ``` 194 | 195 | **Valibot Example:** 196 | 197 | Valibot requires the peer dependency @valibot/to-json-schema. 198 | 199 | ```typescript 200 | import * as v from "valibot"; 201 | 202 | server.addTool({ 203 | name: "fetch-valibot", 204 | description: "Fetch the content of a url (using Valibot)", 205 | parameters: v.object({ 206 | url: v.string(), 207 | }), 208 | execute: async (args) => { 209 | return await fetchWebpageContent(args.url); 210 | }, 211 | }); 212 | ``` 213 | 214 | #### Tools Without Parameters 215 | 216 | When creating tools that don't require parameters, you have two options: 217 | 218 | 1. Omit the parameters property entirely: 219 | 220 | ```typescript 221 | server.addTool({ 222 | name: "sayHello", 223 | description: "Say hello", 224 | // No parameters property 225 | execute: async () => { 226 | return "Hello, world!"; 227 | }, 228 | }); 229 | ``` 230 | 231 | 2. Explicitly define empty parameters: 232 | 233 | ```typescript 234 | import { z } from "zod"; 235 | 236 | server.addTool({ 237 | name: "sayHello", 238 | description: "Say hello", 239 | parameters: z.object({}), // Empty object 240 | execute: async () => { 241 | return "Hello, world!"; 242 | }, 243 | }); 244 | ``` 245 | 246 | > [!NOTE] 247 | > 248 | > Both approaches are fully compatible with all MCP clients, including Cursor. FastMCP automatically generates the proper schema in both cases. 249 | 250 | #### Returning a string 251 | 252 | `execute` can return a string: 253 | 254 | ```js 255 | server.addTool({ 256 | name: "download", 257 | description: "Download a file", 258 | parameters: z.object({ 259 | url: z.string(), 260 | }), 261 | execute: async (args) => { 262 | return "Hello, world!"; 263 | }, 264 | }); 265 | ``` 266 | 267 | The latter is equivalent to: 268 | 269 | ```js 270 | server.addTool({ 271 | name: "download", 272 | description: "Download a file", 273 | parameters: z.object({ 274 | url: z.string(), 275 | }), 276 | execute: async (args) => { 277 | return { 278 | content: [ 279 | { 280 | type: "text", 281 | text: "Hello, world!", 282 | }, 283 | ], 284 | }; 285 | }, 286 | }); 287 | ``` 288 | 289 | #### Returning a list 290 | 291 | If you want to return a list of messages, you can return an object with a `content` property: 292 | 293 | ```js 294 | server.addTool({ 295 | name: "download", 296 | description: "Download a file", 297 | parameters: z.object({ 298 | url: z.string(), 299 | }), 300 | execute: async (args) => { 301 | return { 302 | content: [ 303 | { type: "text", text: "First message" }, 304 | { type: "text", text: "Second message" }, 305 | ], 306 | }; 307 | }, 308 | }); 309 | ``` 310 | 311 | #### Returning an image 312 | 313 | Use the `imageContent` to create a content object for an image: 314 | 315 | ```js 316 | import { imageContent } from "fastmcp"; 317 | 318 | server.addTool({ 319 | name: "download", 320 | description: "Download a file", 321 | parameters: z.object({ 322 | url: z.string(), 323 | }), 324 | execute: async (args) => { 325 | return imageContent({ 326 | url: "https://example.com/image.png", 327 | }); 328 | 329 | // or... 330 | // return imageContent({ 331 | // path: "/path/to/image.png", 332 | // }); 333 | 334 | // or... 335 | // return imageContent({ 336 | // buffer: Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=", "base64"), 337 | // }); 338 | 339 | // or... 340 | // return { 341 | // content: [ 342 | // await imageContent(...) 343 | // ], 344 | // }; 345 | }, 346 | }); 347 | ``` 348 | 349 | The `imageContent` function takes the following options: 350 | 351 | - `url`: The URL of the image. 352 | - `path`: The path to the image file. 353 | - `buffer`: The image data as a buffer. 354 | 355 | Only one of `url`, `path`, or `buffer` must be specified. 356 | 357 | The above example is equivalent to: 358 | 359 | ```js 360 | server.addTool({ 361 | name: "download", 362 | description: "Download a file", 363 | parameters: z.object({ 364 | url: z.string(), 365 | }), 366 | execute: async (args) => { 367 | return { 368 | content: [ 369 | { 370 | type: "image", 371 | data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=", 372 | mimeType: "image/png", 373 | }, 374 | ], 375 | }; 376 | }, 377 | }); 378 | ``` 379 | 380 | #### Configurable Ping Behavior 381 | 382 | FastMCP includes a configurable ping mechanism to maintain connection health. The ping behavior can be customized through server options: 383 | 384 | ```ts 385 | const server = new FastMCP({ 386 | name: "My Server", 387 | version: "1.0.0", 388 | ping: { 389 | // Explicitly enable or disable pings (defaults vary by transport) 390 | enabled: true, 391 | // Configure ping interval in milliseconds (default: 5000ms) 392 | intervalMs: 10000, 393 | // Set log level for ping-related messages (default: 'debug') 394 | logLevel: "debug", 395 | }, 396 | }); 397 | ``` 398 | 399 | By default, ping behavior is optimized for each transport type: 400 | 401 | - Enabled for SSE and HTTP streaming connections (which benefit from keep-alive) 402 | - Disabled for `stdio` connections (where pings are typically unnecessary) 403 | 404 | This configurable approach helps reduce log verbosity and optimize performance for different usage scenarios. 405 | 406 | ### Health-check Endpoint 407 | 408 | When you run FastMCP with the `httpStream` transport you can optionally expose a 409 | simple HTTP endpoint that returns a plain-text response useful for load-balancer 410 | or container orchestration liveness checks. 411 | 412 | Enable (or customise) the endpoint via the `health` key in the server options: 413 | 414 | ```ts 415 | const server = new FastMCP({ 416 | name: "My Server", 417 | version: "1.0.0", 418 | health: { 419 | // Enable / disable (default: true) 420 | enabled: true, 421 | // Body returned by the endpoint (default: 'ok') 422 | message: "healthy", 423 | // Path that should respond (default: '/health') 424 | path: "/healthz", 425 | // HTTP status code to return (default: 200) 426 | status: 200, 427 | }, 428 | }); 429 | 430 | await server.start({ 431 | transportType: "httpStream", 432 | httpStream: { port: 8080 }, 433 | }); 434 | ``` 435 | 436 | Now a request to `http://localhost:8080/healthz` will return: 437 | 438 | ``` 439 | HTTP/1.1 200 OK 440 | content-type: text/plain 441 | 442 | healthy 443 | ``` 444 | 445 | The endpoint is ignored when the server is started with the `stdio` transport. 446 | 447 | #### Roots Management 448 | 449 | FastMCP supports [Roots](https://modelcontextprotocol.io/docs/concepts/roots) - Feature that allows clients to provide a set of filesystem-like root locations that can be listed and dynamically updated. The Roots feature can be configured or disabled in server options: 450 | 451 | ```ts 452 | const server = new FastMCP({ 453 | name: "My Server", 454 | version: "1.0.0", 455 | roots: { 456 | // Set to false to explicitly disable roots support 457 | enabled: false, 458 | // By default, roots support is enabled (true) 459 | }, 460 | }); 461 | ``` 462 | 463 | This provides the following benefits: 464 | 465 | - Better compatibility with different clients that may not support Roots 466 | - Reduced error logs when connecting to clients that don't implement roots capability 467 | - More explicit control over MCP server capabilities 468 | - Graceful degradation when roots functionality isn't available 469 | 470 | You can listen for root changes in your server: 471 | 472 | ```ts 473 | server.on("connect", (event) => { 474 | const session = event.session; 475 | 476 | // Access the current roots 477 | console.log("Initial roots:", session.roots); 478 | 479 | // Listen for changes to the roots 480 | session.on("rootsChanged", (event) => { 481 | console.log("Roots changed:", event.roots); 482 | }); 483 | }); 484 | ``` 485 | 486 | When a client doesn't support roots or when roots functionality is explicitly disabled, these operations will gracefully handle the situation without throwing errors. 487 | 488 | ### Returning an audio 489 | 490 | Use the `audioContent` to create a content object for an audio: 491 | 492 | ```js 493 | import { audioContent } from "fastmcp"; 494 | 495 | server.addTool({ 496 | name: "download", 497 | description: "Download a file", 498 | parameters: z.object({ 499 | url: z.string(), 500 | }), 501 | execute: async (args) => { 502 | return audioContent({ 503 | url: "https://example.com/audio.mp3", 504 | }); 505 | 506 | // or... 507 | // return audioContent({ 508 | // path: "/path/to/audio.mp3", 509 | // }); 510 | 511 | // or... 512 | // return audioContent({ 513 | // buffer: Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=", "base64"), 514 | // }); 515 | 516 | // or... 517 | // return { 518 | // content: [ 519 | // await audioContent(...) 520 | // ], 521 | // }; 522 | }, 523 | }); 524 | ``` 525 | 526 | The `audioContent` function takes the following options: 527 | 528 | - `url`: The URL of the audio. 529 | - `path`: The path to the audio file. 530 | - `buffer`: The audio data as a buffer. 531 | 532 | Only one of `url`, `path`, or `buffer` must be specified. 533 | 534 | The above example is equivalent to: 535 | 536 | ```js 537 | server.addTool({ 538 | name: "download", 539 | description: "Download a file", 540 | parameters: z.object({ 541 | url: z.string(), 542 | }), 543 | execute: async (args) => { 544 | return { 545 | content: [ 546 | { 547 | type: "audio", 548 | data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=", 549 | mimeType: "audio/mpeg", 550 | }, 551 | ], 552 | }; 553 | }, 554 | }); 555 | ``` 556 | 557 | #### Return combination type 558 | 559 | You can combine various types in this way and send them back to AI 560 | 561 | ```js 562 | server.addTool({ 563 | name: "download", 564 | description: "Download a file", 565 | parameters: z.object({ 566 | url: z.string(), 567 | }), 568 | execute: async (args) => { 569 | return { 570 | content: [ 571 | { 572 | type: "text", 573 | text: "Hello, world!", 574 | }, 575 | { 576 | type: "image", 577 | data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=", 578 | mimeType: "image/png", 579 | }, 580 | { 581 | type: "audio", 582 | data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=", 583 | mimeType: "audio/mpeg", 584 | }, 585 | ], 586 | }; 587 | }, 588 | 589 | // or... 590 | // execute: async (args) => { 591 | // const imgContent = await imageContent({ 592 | // url: "https://example.com/image.png", 593 | // }); 594 | // const audContent = await audioContent({ 595 | // url: "https://example.com/audio.mp3", 596 | // }); 597 | // return { 598 | // content: [ 599 | // { 600 | // type: "text", 601 | // text: "Hello, world!", 602 | // }, 603 | // imgContent, 604 | // audContent, 605 | // ], 606 | // }; 607 | // }, 608 | }); 609 | ``` 610 | 611 | #### Logging 612 | 613 | Tools can log messages to the client using the `log` object in the context object: 614 | 615 | ```js 616 | server.addTool({ 617 | name: "download", 618 | description: "Download a file", 619 | parameters: z.object({ 620 | url: z.string(), 621 | }), 622 | execute: async (args, { log }) => { 623 | log.info("Downloading file...", { 624 | url, 625 | }); 626 | 627 | // ... 628 | 629 | log.info("Downloaded file"); 630 | 631 | return "done"; 632 | }, 633 | }); 634 | ``` 635 | 636 | The `log` object has the following methods: 637 | 638 | - `debug(message: string, data?: SerializableValue)` 639 | - `error(message: string, data?: SerializableValue)` 640 | - `info(message: string, data?: SerializableValue)` 641 | - `warn(message: string, data?: SerializableValue)` 642 | 643 | #### Errors 644 | 645 | The errors that are meant to be shown to the user should be thrown as `UserError` instances: 646 | 647 | ```js 648 | import { UserError } from "fastmcp"; 649 | 650 | server.addTool({ 651 | name: "download", 652 | description: "Download a file", 653 | parameters: z.object({ 654 | url: z.string(), 655 | }), 656 | execute: async (args) => { 657 | if (args.url.startsWith("https://example.com")) { 658 | throw new UserError("This URL is not allowed"); 659 | } 660 | 661 | return "done"; 662 | }, 663 | }); 664 | ``` 665 | 666 | #### Progress 667 | 668 | Tools can report progress by calling `reportProgress` in the context object: 669 | 670 | ```js 671 | server.addTool({ 672 | name: "download", 673 | description: "Download a file", 674 | parameters: z.object({ 675 | url: z.string(), 676 | }), 677 | execute: async (args, { reportProgress }) => { 678 | reportProgress({ 679 | progress: 0, 680 | total: 100, 681 | }); 682 | 683 | // ... 684 | 685 | reportProgress({ 686 | progress: 100, 687 | total: 100, 688 | }); 689 | 690 | return "done"; 691 | }, 692 | }); 693 | ``` 694 | 695 | #### Streaming Output 696 | 697 | FastMCP supports streaming partial results from tools while they're still executing, enabling responsive UIs and real-time feedback. This is particularly useful for: 698 | 699 | - Long-running operations that generate content incrementally 700 | - Progressive generation of text, images, or other media 701 | - Operations where users benefit from seeing immediate partial results 702 | 703 | To enable streaming for a tool, add the `streamingHint` annotation and use the `streamContent` method: 704 | 705 | ```js 706 | server.addTool({ 707 | name: "generateText", 708 | description: "Generate text incrementally", 709 | parameters: z.object({ 710 | prompt: z.string(), 711 | }), 712 | annotations: { 713 | streamingHint: true, // Signals this tool uses streaming 714 | readOnlyHint: true, 715 | }, 716 | execute: async (args, { streamContent }) => { 717 | // Send initial content immediately 718 | await streamContent({ type: "text", text: "Starting generation...\n" }); 719 | 720 | // Simulate incremental content generation 721 | const words = "The quick brown fox jumps over the lazy dog.".split(" "); 722 | for (const word of words) { 723 | await streamContent({ type: "text", text: word + " " }); 724 | await new Promise((resolve) => setTimeout(resolve, 300)); // Simulate delay 725 | } 726 | 727 | // When using streamContent, you can: 728 | // 1. Return void (if all content was streamed) 729 | // 2. Return a final result (which will be appended to streamed content) 730 | 731 | // Option 1: All content was streamed, so return void 732 | return; 733 | 734 | // Option 2: Return final content that will be appended 735 | // return "Generation complete!"; 736 | }, 737 | }); 738 | ``` 739 | 740 | Streaming works with all content types (text, image, audio) and can be combined with progress reporting: 741 | 742 | ```js 743 | server.addTool({ 744 | name: "processData", 745 | description: "Process data with streaming updates", 746 | parameters: z.object({ 747 | datasetSize: z.number(), 748 | }), 749 | annotations: { 750 | streamingHint: true, 751 | }, 752 | execute: async (args, { streamContent, reportProgress }) => { 753 | const total = args.datasetSize; 754 | 755 | for (let i = 0; i < total; i++) { 756 | // Report numeric progress 757 | await reportProgress({ progress: i, total }); 758 | 759 | // Stream intermediate results 760 | if (i % 10 === 0) { 761 | await streamContent({ 762 | type: "text", 763 | text: `Processed ${i} of ${total} items...\n`, 764 | }); 765 | } 766 | 767 | await new Promise((resolve) => setTimeout(resolve, 50)); 768 | } 769 | 770 | return "Processing complete!"; 771 | }, 772 | }); 773 | ``` 774 | 775 | #### Tool Annotations 776 | 777 | As of the MCP Specification (2025-03-26), tools can include annotations that provide richer context and control by adding metadata about a tool's behavior: 778 | 779 | ```typescript 780 | server.addTool({ 781 | name: "fetch-content", 782 | description: "Fetch content from a URL", 783 | parameters: z.object({ 784 | url: z.string(), 785 | }), 786 | annotations: { 787 | title: "Web Content Fetcher", // Human-readable title for UI display 788 | readOnlyHint: true, // Tool doesn't modify its environment 789 | openWorldHint: true, // Tool interacts with external entities 790 | }, 791 | execute: async (args) => { 792 | return await fetchWebpageContent(args.url); 793 | }, 794 | }); 795 | ``` 796 | 797 | The available annotations are: 798 | 799 | | Annotation | Type | Default | Description | 800 | | :---------------- | :------ | :------ | :----------------------------------------------------------------------------------------------------------------------------------- | 801 | | `title` | string | - | A human-readable title for the tool, useful for UI display | 802 | | `readOnlyHint` | boolean | `false` | If true, indicates the tool does not modify its environment | 803 | | `destructiveHint` | boolean | `true` | If true, the tool may perform destructive updates (only meaningful when `readOnlyHint` is false) | 804 | | `idempotentHint` | boolean | `false` | If true, calling the tool repeatedly with the same arguments has no additional effect (only meaningful when `readOnlyHint` is false) | 805 | | `openWorldHint` | boolean | `true` | If true, the tool may interact with an "open world" of external entities | 806 | 807 | These annotations help clients and LLMs better understand how to use the tools and what to expect when calling them. 808 | 809 | ### Resources 810 | 811 | [Resources](https://modelcontextprotocol.io/docs/concepts/resources) represent any kind of data that an MCP server wants to make available to clients. This can include: 812 | 813 | - File contents 814 | - Screenshots and images 815 | - Log files 816 | - And more 817 | 818 | Each resource is identified by a unique URI and can contain either text or binary data. 819 | 820 | ```ts 821 | server.addResource({ 822 | uri: "file:///logs/app.log", 823 | name: "Application Logs", 824 | mimeType: "text/plain", 825 | async load() { 826 | return { 827 | text: await readLogFile(), 828 | }; 829 | }, 830 | }); 831 | ``` 832 | 833 | > [!NOTE] 834 | > 835 | > `load` can return multiple resources. This could be used, for example, to return a list of files inside a directory when the directory is read. 836 | > 837 | > ```ts 838 | > async load() { 839 | > return [ 840 | > { 841 | > text: "First file content", 842 | > }, 843 | > { 844 | > text: "Second file content", 845 | > }, 846 | > ]; 847 | > } 848 | > ``` 849 | 850 | You can also return binary contents in `load`: 851 | 852 | ```ts 853 | async load() { 854 | return { 855 | blob: 'base64-encoded-data' 856 | }; 857 | } 858 | ``` 859 | 860 | ### Resource templates 861 | 862 | You can also define resource templates: 863 | 864 | ```ts 865 | server.addResourceTemplate({ 866 | uriTemplate: "file:///logs/{name}.log", 867 | name: "Application Logs", 868 | mimeType: "text/plain", 869 | arguments: [ 870 | { 871 | name: "name", 872 | description: "Name of the log", 873 | required: true, 874 | }, 875 | ], 876 | async load({ name }) { 877 | return { 878 | text: `Example log content for ${name}`, 879 | }; 880 | }, 881 | }); 882 | ``` 883 | 884 | #### Resource template argument auto-completion 885 | 886 | Provide `complete` functions for resource template arguments to enable automatic completion: 887 | 888 | ```ts 889 | server.addResourceTemplate({ 890 | uriTemplate: "file:///logs/{name}.log", 891 | name: "Application Logs", 892 | mimeType: "text/plain", 893 | arguments: [ 894 | { 895 | name: "name", 896 | description: "Name of the log", 897 | required: true, 898 | complete: async (value) => { 899 | if (value === "Example") { 900 | return { 901 | values: ["Example Log"], 902 | }; 903 | } 904 | 905 | return { 906 | values: [], 907 | }; 908 | }, 909 | }, 910 | ], 911 | async load({ name }) { 912 | return { 913 | text: `Example log content for ${name}`, 914 | }; 915 | }, 916 | }); 917 | ``` 918 | 919 | ### Embedded Resources 920 | 921 | FastMCP provides a convenient `embedded()` method that simplifies including resources in tool responses. This feature reduces code duplication and makes it easier to reference resources from within tools. 922 | 923 | #### Basic Usage 924 | 925 | ```js 926 | server.addTool({ 927 | name: "get_user_data", 928 | description: "Retrieve user information", 929 | parameters: z.object({ 930 | userId: z.string(), 931 | }), 932 | execute: async (args) => { 933 | return { 934 | content: [ 935 | { 936 | type: "resource", 937 | resource: await server.embedded(`user://profile/${args.userId}`), 938 | }, 939 | ], 940 | }; 941 | }, 942 | }); 943 | ``` 944 | 945 | #### Working with Resource Templates 946 | 947 | The `embedded()` method works seamlessly with resource templates: 948 | 949 | ```js 950 | // Define a resource template 951 | server.addResourceTemplate({ 952 | uriTemplate: "docs://project/{section}", 953 | name: "Project Documentation", 954 | mimeType: "text/markdown", 955 | arguments: [ 956 | { 957 | name: "section", 958 | required: true, 959 | }, 960 | ], 961 | async load(args) { 962 | const docs = { 963 | "getting-started": "# Getting Started\n\nWelcome to our project!", 964 | "api-reference": "# API Reference\n\nAuthentication is required.", 965 | }; 966 | return { 967 | text: docs[args.section] || "Documentation not found", 968 | }; 969 | }, 970 | }); 971 | 972 | // Use embedded resources in a tool 973 | server.addTool({ 974 | name: "get_documentation", 975 | description: "Retrieve project documentation", 976 | parameters: z.object({ 977 | section: z.enum(["getting-started", "api-reference"]), 978 | }), 979 | execute: async (args) => { 980 | return { 981 | content: [ 982 | { 983 | type: "resource", 984 | resource: await server.embedded(`docs://project/${args.section}`), 985 | }, 986 | ], 987 | }; 988 | }, 989 | }); 990 | ``` 991 | 992 | #### Working with Direct Resources 993 | 994 | It also works with directly defined resources: 995 | 996 | ```js 997 | // Define a direct resource 998 | server.addResource({ 999 | uri: "system://status", 1000 | name: "System Status", 1001 | mimeType: "text/plain", 1002 | async load() { 1003 | return { 1004 | text: "System operational", 1005 | }; 1006 | }, 1007 | }); 1008 | 1009 | // Use in a tool 1010 | server.addTool({ 1011 | name: "get_system_status", 1012 | description: "Get current system status", 1013 | parameters: z.object({}), 1014 | execute: async () => { 1015 | return { 1016 | content: [ 1017 | { 1018 | type: "resource", 1019 | resource: await server.embedded("system://status"), 1020 | }, 1021 | ], 1022 | }; 1023 | }, 1024 | }); 1025 | ``` 1026 | 1027 | ### Prompts 1028 | 1029 | [Prompts](https://modelcontextprotocol.io/docs/concepts/prompts) enable servers to define reusable prompt templates and workflows that clients can easily surface to users and LLMs. They provide a powerful way to standardize and share common LLM interactions. 1030 | 1031 | ```ts 1032 | server.addPrompt({ 1033 | name: "git-commit", 1034 | description: "Generate a Git commit message", 1035 | arguments: [ 1036 | { 1037 | name: "changes", 1038 | description: "Git diff or description of changes", 1039 | required: true, 1040 | }, 1041 | ], 1042 | load: async (args) => { 1043 | return `Generate a concise but descriptive commit message for these changes:\n\n${args.changes}`; 1044 | }, 1045 | }); 1046 | ``` 1047 | 1048 | #### Prompt argument auto-completion 1049 | 1050 | Prompts can provide auto-completion for their arguments: 1051 | 1052 | ```js 1053 | server.addPrompt({ 1054 | name: "countryPoem", 1055 | description: "Writes a poem about a country", 1056 | load: async ({ name }) => { 1057 | return `Hello, ${name}!`; 1058 | }, 1059 | arguments: [ 1060 | { 1061 | name: "name", 1062 | description: "Name of the country", 1063 | required: true, 1064 | complete: async (value) => { 1065 | if (value === "Germ") { 1066 | return { 1067 | values: ["Germany"], 1068 | }; 1069 | } 1070 | 1071 | return { 1072 | values: [], 1073 | }; 1074 | }, 1075 | }, 1076 | ], 1077 | }); 1078 | ``` 1079 | 1080 | #### Prompt argument auto-completion using `enum` 1081 | 1082 | If you provide an `enum` array for an argument, the server will automatically provide completions for the argument. 1083 | 1084 | ```js 1085 | server.addPrompt({ 1086 | name: "countryPoem", 1087 | description: "Writes a poem about a country", 1088 | load: async ({ name }) => { 1089 | return `Hello, ${name}!`; 1090 | }, 1091 | arguments: [ 1092 | { 1093 | name: "name", 1094 | description: "Name of the country", 1095 | required: true, 1096 | enum: ["Germany", "France", "Italy"], 1097 | }, 1098 | ], 1099 | }); 1100 | ``` 1101 | 1102 | ### Authentication 1103 | 1104 | FastMCP allows you to `authenticate` clients using a custom function: 1105 | 1106 | ```ts 1107 | const server = new FastMCP({ 1108 | name: "My Server", 1109 | version: "1.0.0", 1110 | authenticate: (request) => { 1111 | const apiKey = request.headers["x-api-key"]; 1112 | 1113 | if (apiKey !== "123") { 1114 | throw new Response(null, { 1115 | status: 401, 1116 | statusText: "Unauthorized", 1117 | }); 1118 | } 1119 | 1120 | // Whatever you return here will be accessible in the `context.session` object. 1121 | return { 1122 | id: 1, 1123 | }; 1124 | }, 1125 | }); 1126 | ``` 1127 | 1128 | Now you can access the authenticated session data in your tools: 1129 | 1130 | ```ts 1131 | server.addTool({ 1132 | name: "sayHello", 1133 | execute: async (args, { session }) => { 1134 | return `Hello, ${session.id}!`; 1135 | }, 1136 | }); 1137 | ``` 1138 | 1139 | ### Providing Instructions 1140 | 1141 | You can provide instructions to the server using the `instructions` option: 1142 | 1143 | ```ts 1144 | const server = new FastMCP({ 1145 | name: "My Server", 1146 | version: "1.0.0", 1147 | instructions: 1148 | 'Instructions describing how to use the server and its features.\n\nThis can be used by clients to improve the LLM\'s understanding of available tools, resources, etc. It can be thought of like a "hint" to the model. For example, this information MAY be added to the system prompt.', 1149 | }); 1150 | ``` 1151 | 1152 | ### Sessions 1153 | 1154 | The `session` object is an instance of `FastMCPSession` and it describes active client sessions. 1155 | 1156 | ```ts 1157 | server.sessions; 1158 | ``` 1159 | 1160 | We allocate a new server instance for each client connection to enable 1:1 communication between a client and the server. 1161 | 1162 | ### Typed server events 1163 | 1164 | You can listen to events emitted by the server using the `on` method: 1165 | 1166 | ```ts 1167 | server.on("connect", (event) => { 1168 | console.log("Client connected:", event.session); 1169 | }); 1170 | 1171 | server.on("disconnect", (event) => { 1172 | console.log("Client disconnected:", event.session); 1173 | }); 1174 | ``` 1175 | 1176 | ## `FastMCPSession` 1177 | 1178 | `FastMCPSession` represents a client session and provides methods to interact with the client. 1179 | 1180 | Refer to [Sessions](#sessions) for examples of how to obtain a `FastMCPSession` instance. 1181 | 1182 | ### `requestSampling` 1183 | 1184 | `requestSampling` creates a [sampling](https://modelcontextprotocol.io/docs/concepts/sampling) request and returns the response. 1185 | 1186 | ```ts 1187 | await session.requestSampling({ 1188 | messages: [ 1189 | { 1190 | role: "user", 1191 | content: { 1192 | type: "text", 1193 | text: "What files are in the current directory?", 1194 | }, 1195 | }, 1196 | ], 1197 | systemPrompt: "You are a helpful file system assistant.", 1198 | includeContext: "thisServer", 1199 | maxTokens: 100, 1200 | }); 1201 | ``` 1202 | 1203 | ### `clientCapabilities` 1204 | 1205 | The `clientCapabilities` property contains the client capabilities. 1206 | 1207 | ```ts 1208 | session.clientCapabilities; 1209 | ``` 1210 | 1211 | ### `loggingLevel` 1212 | 1213 | The `loggingLevel` property describes the logging level as set by the client. 1214 | 1215 | ```ts 1216 | session.loggingLevel; 1217 | ``` 1218 | 1219 | ### `roots` 1220 | 1221 | The `roots` property contains the roots as set by the client. 1222 | 1223 | ```ts 1224 | session.roots; 1225 | ``` 1226 | 1227 | ### `server` 1228 | 1229 | The `server` property contains an instance of MCP server that is associated with the session. 1230 | 1231 | ```ts 1232 | session.server; 1233 | ``` 1234 | 1235 | ### Typed session events 1236 | 1237 | You can listen to events emitted by the session using the `on` method: 1238 | 1239 | ```ts 1240 | session.on("rootsChanged", (event) => { 1241 | console.log("Roots changed:", event.roots); 1242 | }); 1243 | 1244 | session.on("error", (event) => { 1245 | console.error("Error:", event.error); 1246 | }); 1247 | ``` 1248 | 1249 | ## Running Your Server 1250 | 1251 | ### Test with `mcp-cli` 1252 | 1253 | The fastest way to test and debug your server is with `fastmcp dev`: 1254 | 1255 | ```bash 1256 | npx fastmcp dev server.js 1257 | npx fastmcp dev server.ts 1258 | ``` 1259 | 1260 | This will run your server with [`mcp-cli`](https://github.com/wong2/mcp-cli) for testing and debugging your MCP server in the terminal. 1261 | 1262 | ### Inspect with `MCP Inspector` 1263 | 1264 | Another way is to use the official [`MCP Inspector`](https://modelcontextprotocol.io/docs/tools/inspector) to inspect your server with a Web UI: 1265 | 1266 | ```bash 1267 | npx fastmcp inspect server.ts 1268 | ``` 1269 | 1270 | ## FAQ 1271 | 1272 | ### How to use with Claude Desktop? 1273 | 1274 | Follow the guide https://modelcontextprotocol.io/quickstart/user and add the following configuration: 1275 | 1276 | ```json 1277 | { 1278 | "mcpServers": { 1279 | "my-mcp-server": { 1280 | "command": "npx", 1281 | "args": ["tsx", "/PATH/TO/YOUR_PROJECT/src/index.ts"], 1282 | "env": { 1283 | "YOUR_ENV_VAR": "value" 1284 | } 1285 | } 1286 | } 1287 | } 1288 | ``` 1289 | 1290 | ## Showcase 1291 | 1292 | > [!NOTE] 1293 | > 1294 | > If you've developed a server using FastMCP, please [submit a PR](https://github.com/punkpeye/fastmcp) to showcase it here! 1295 | 1296 | > [!NOTE] 1297 | > 1298 | > If you are looking for a boilerplate repository to build your own MCP server, check out [fastmcp-boilerplate](https://github.com/punkpeye/fastmcp-boilerplate). 1299 | 1300 | - [apinetwork/piapi-mcp-server](https://github.com/apinetwork/piapi-mcp-server) - generate media using Midjourney/Flux/Kling/LumaLabs/Udio/Chrip/Trellis 1301 | - [domdomegg/computer-use-mcp](https://github.com/domdomegg/computer-use-mcp) - controls your computer 1302 | - [LiterallyBlah/Dradis-MCP](https://github.com/LiterallyBlah/Dradis-MCP) – manages projects and vulnerabilities in Dradis 1303 | - [Meeting-Baas/meeting-mcp](https://github.com/Meeting-Baas/meeting-mcp) - create meeting bots, search transcripts, and manage recording data 1304 | - [drumnation/unsplash-smart-mcp-server](https://github.com/drumnation/unsplash-smart-mcp-server) – enables AI agents to seamlessly search, recommend, and deliver professional stock photos from Unsplash 1305 | - [ssmanji89/halopsa-workflows-mcp](https://github.com/ssmanji89/halopsa-workflows-mcp) - HaloPSA Workflows integration with AI assistants 1306 | - [aiamblichus/mcp-chat-adapter](https://github.com/aiamblichus/mcp-chat-adapter) – provides a clean interface for LLMs to use chat completion 1307 | - [eyaltoledano/claude-task-master](https://github.com/eyaltoledano/claude-task-master) – advanced AI project/task manager powered by FastMCP 1308 | - [cswkim/discogs-mcp-server](https://github.com/cswkim/discogs-mcp-server) - connects to the Discogs API for interacting with your music collection 1309 | - [Panzer-Jack/feuse-mcp](https://github.com/Panzer-Jack/feuse-mcp) - Frontend Useful MCP Tools - Essential utilities for web developers to automate API integration and code generation 1310 | 1311 | ## Acknowledgements 1312 | 1313 | - FastMCP is inspired by the [Python implementation](https://github.com/jlowin/fastmcp) by [Jonathan Lowin](https://github.com/jlowin). 1314 | - Parts of codebase were adopted from [LiteMCP](https://github.com/wong2/litemcp). 1315 | - Parts of codebase were adopted from [Model Context protocolでSSEをやってみる](https://dev.classmethod.jp/articles/mcp-sse/). 1316 | -------------------------------------------------------------------------------- /eslint.config.ts: -------------------------------------------------------------------------------- 1 | import eslint from "@eslint/js"; 2 | import eslintConfigPrettier from "eslint-config-prettier/flat"; 3 | import perfectionist from "eslint-plugin-perfectionist"; 4 | import tseslint from "typescript-eslint"; 5 | 6 | export default tseslint.config( 7 | eslint.configs.recommended, 8 | tseslint.configs.recommended, 9 | perfectionist.configs["recommended-alphabetical"], 10 | eslintConfigPrettier, 11 | { 12 | ignores: ["**/*.js"], 13 | }, 14 | ); 15 | -------------------------------------------------------------------------------- /jsr.json: -------------------------------------------------------------------------------- 1 | { 2 | "exports": "./src/FastMCP.ts", 3 | "include": ["src/FastMCP.ts", "src/bin/fastmcp.ts"], 4 | "license": "MIT", 5 | "name": "@punkpeye/fastmcp", 6 | "version": "1.0.0" 7 | } 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fastmcp", 3 | "version": "1.0.0", 4 | "main": "dist/FastMCP.js", 5 | "scripts": { 6 | "build": "tsup", 7 | "lint": "prettier --check . && eslint . && tsc --noEmit && jsr publish --dry-run", 8 | "test": "vitest run", 9 | "format": "prettier --write . && eslint --fix ." 10 | }, 11 | "bin": { 12 | "fastmcp": "dist/bin/fastmcp.js" 13 | }, 14 | "keywords": [ 15 | "MCP", 16 | "SSE" 17 | ], 18 | "type": "module", 19 | "author": "Frank Fiegel ", 20 | "license": "MIT", 21 | "description": "A TypeScript framework for building MCP servers.", 22 | "module": "dist/FastMCP.js", 23 | "types": "dist/FastMCP.d.ts", 24 | "dependencies": { 25 | "@modelcontextprotocol/sdk": "^1.10.2", 26 | "@standard-schema/spec": "^1.0.0", 27 | "execa": "^9.5.2", 28 | "file-type": "^20.4.1", 29 | "fuse.js": "^7.1.0", 30 | "mcp-proxy": "^3.0.3", 31 | "strict-event-emitter-types": "^2.0.0", 32 | "undici": "^7.8.0", 33 | "uri-templates": "^0.2.0", 34 | "xsschema": "0.3.0-beta.1", 35 | "yargs": "^17.7.2", 36 | "zod": "^3.25.12", 37 | "zod-to-json-schema": "^3.24.5" 38 | }, 39 | "repository": { 40 | "url": "https://github.com/punkpeye/fastmcp" 41 | }, 42 | "homepage": "https://glama.ai/mcp", 43 | "release": { 44 | "branches": [ 45 | "main" 46 | ], 47 | "plugins": [ 48 | "@semantic-release/commit-analyzer", 49 | "@semantic-release/release-notes-generator", 50 | "@semantic-release/npm", 51 | "@semantic-release/github", 52 | "@sebbo2002/semantic-release-jsr" 53 | ] 54 | }, 55 | "devDependencies": { 56 | "@eslint/js": "^9.27.0", 57 | "@modelcontextprotocol/inspector": "^0.11.0", 58 | "@sebbo2002/semantic-release-jsr": "^2.0.5", 59 | "@tsconfig/node22": "^22.0.1", 60 | "@types/node": "^22.14.1", 61 | "@types/uri-templates": "^0.1.34", 62 | "@types/yargs": "^17.0.33", 63 | "@valibot/to-json-schema": "^1.0.0", 64 | "@wong2/mcp-cli": "^1.10.0", 65 | "arktype": "^2.1.20", 66 | "eslint": "^9.27.0", 67 | "eslint-config-prettier": "^10.1.5", 68 | "eslint-plugin-perfectionist": "^4.13.0", 69 | "eslint-plugin-prettier": "^5.4.0", 70 | "eventsource-client": "^1.1.3", 71 | "get-port-please": "^3.1.2", 72 | "jiti": "^2.4.2", 73 | "jsr": "^0.13.4", 74 | "prettier": "^3.5.3", 75 | "semantic-release": "^24.2.3", 76 | "tsup": "^8.4.0", 77 | "typescript": "^5.8.3", 78 | "typescript-eslint": "^8.32.1", 79 | "valibot": "^1.0.0", 80 | "vitest": "^3.1.2" 81 | }, 82 | "tsup": { 83 | "entry": [ 84 | "src/FastMCP.ts", 85 | "src/bin/fastmcp.ts" 86 | ], 87 | "format": [ 88 | "esm" 89 | ], 90 | "dts": true, 91 | "splitting": true, 92 | "sourcemap": true, 93 | "clean": true 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/FastMCP.test.ts: -------------------------------------------------------------------------------- 1 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; 2 | import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; 3 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; 4 | import { 5 | CreateMessageRequestSchema, 6 | ErrorCode, 7 | ListRootsRequestSchema, 8 | LoggingMessageNotificationSchema, 9 | McpError, 10 | PingRequestSchema, 11 | Root, 12 | } from "@modelcontextprotocol/sdk/types.js"; 13 | import { createEventSource, EventSourceClient } from "eventsource-client"; 14 | import { getRandomPort } from "get-port-please"; 15 | import { setTimeout as delay } from "timers/promises"; 16 | import { fetch } from "undici"; 17 | import { expect, test, vi } from "vitest"; 18 | import { z } from "zod"; 19 | import { z as z4 } from "zod/v4"; 20 | 21 | import { 22 | audioContent, 23 | type ContentResult, 24 | FastMCP, 25 | FastMCPSession, 26 | imageContent, 27 | type TextContent, 28 | UserError, 29 | } from "./FastMCP.js"; 30 | 31 | const runWithTestServer = async ({ 32 | client: createClient, 33 | run, 34 | server: createServer, 35 | }: { 36 | client?: () => Promise; 37 | run: ({ 38 | client, 39 | server, 40 | }: { 41 | client: Client; 42 | server: FastMCP; 43 | session: FastMCPSession; 44 | }) => Promise; 45 | server?: () => Promise; 46 | }) => { 47 | const port = await getRandomPort(); 48 | 49 | const server = createServer 50 | ? await createServer() 51 | : new FastMCP({ 52 | name: "Test", 53 | version: "1.0.0", 54 | }); 55 | 56 | await server.start({ 57 | httpStream: { 58 | port, 59 | }, 60 | transportType: "httpStream", 61 | }); 62 | 63 | try { 64 | const client = createClient 65 | ? await createClient() 66 | : new Client( 67 | { 68 | name: "example-client", 69 | version: "1.0.0", 70 | }, 71 | { 72 | capabilities: {}, 73 | }, 74 | ); 75 | 76 | const transport = new SSEClientTransport( 77 | new URL(`http://localhost:${port}/sse`), 78 | ); 79 | 80 | const session = await new Promise((resolve) => { 81 | server.on("connect", async (event) => { 82 | // Wait for session to be fully ready before resolving 83 | await event.session.waitForReady(); 84 | resolve(event.session); 85 | }); 86 | 87 | client.connect(transport); 88 | }); 89 | 90 | await run({ client, server, session }); 91 | } finally { 92 | await server.stop(); 93 | } 94 | 95 | return port; 96 | }; 97 | 98 | test("adds tools", async () => { 99 | await runWithTestServer({ 100 | run: async ({ client }) => { 101 | expect(await client.listTools()).toEqual({ 102 | tools: [ 103 | { 104 | description: "Add two numbers", 105 | inputSchema: { 106 | $schema: "http://json-schema.org/draft-07/schema#", 107 | additionalProperties: false, 108 | properties: { 109 | a: { type: "number" }, 110 | b: { type: "number" }, 111 | }, 112 | required: ["a", "b"], 113 | type: "object", 114 | }, 115 | name: "add", 116 | }, 117 | ], 118 | }); 119 | }, 120 | server: async () => { 121 | const server = new FastMCP({ 122 | name: "Test", 123 | version: "1.0.0", 124 | }); 125 | 126 | server.addTool({ 127 | description: "Add two numbers", 128 | execute: async (args) => { 129 | return String(args.a + args.b); 130 | }, 131 | name: "add", 132 | parameters: z.object({ 133 | a: z.number(), 134 | b: z.number(), 135 | }), 136 | }); 137 | 138 | return server; 139 | }, 140 | }); 141 | }); 142 | 143 | test("adds tools with Zod v4 schema", async () => { 144 | await runWithTestServer({ 145 | run: async ({ client }) => { 146 | expect(await client.listTools()).toEqual({ 147 | tools: [ 148 | { 149 | description: "Add two numbers (using Zod v4 schema)", 150 | inputSchema: { 151 | $schema: "https://json-schema.org/draft-2020-12/schema", 152 | properties: { 153 | a: { type: "number" }, 154 | b: { type: "number" }, 155 | }, 156 | required: ["a", "b"], 157 | type: "object", 158 | }, 159 | name: "add-zod-v4", 160 | }, 161 | ], 162 | }); 163 | }, 164 | server: async () => { 165 | const server = new FastMCP({ 166 | name: "Test", 167 | version: "1.0.0", 168 | }); 169 | 170 | const AddParamsZod4 = z4.object({ 171 | a: z4.number(), 172 | b: z4.number(), 173 | }); 174 | 175 | server.addTool({ 176 | description: "Add two numbers (using Zod v4 schema)", 177 | execute: async (args) => { 178 | return String(args.a + args.b); 179 | }, 180 | name: "add-zod-v4", 181 | parameters: AddParamsZod4, 182 | }); 183 | 184 | return server; 185 | }, 186 | }); 187 | }); 188 | 189 | test("health endpoint returns ok", async () => { 190 | const port = await getRandomPort(); 191 | 192 | const server = new FastMCP({ 193 | health: { message: "healthy", path: "/healthz" }, 194 | name: "Test", 195 | version: "1.0.0", 196 | }); 197 | 198 | await server.start({ 199 | httpStream: { port }, 200 | transportType: "httpStream", 201 | }); 202 | 203 | try { 204 | const response = await fetch(`http://localhost:${port}/healthz`); 205 | expect(response.status).toBe(200); 206 | expect(await response.text()).toBe("healthy"); 207 | } finally { 208 | await server.stop(); 209 | } 210 | }); 211 | 212 | test("calls a tool", async () => { 213 | await runWithTestServer({ 214 | run: async ({ client }) => { 215 | expect( 216 | await client.callTool({ 217 | arguments: { 218 | a: 1, 219 | b: 2, 220 | }, 221 | name: "add", 222 | }), 223 | ).toEqual({ 224 | content: [{ text: "3", type: "text" }], 225 | }); 226 | }, 227 | server: async () => { 228 | const server = new FastMCP({ 229 | name: "Test", 230 | version: "1.0.0", 231 | }); 232 | 233 | server.addTool({ 234 | description: "Add two numbers", 235 | execute: async (args) => { 236 | return String(args.a + args.b); 237 | }, 238 | name: "add", 239 | parameters: z.object({ 240 | a: z.number(), 241 | b: z.number(), 242 | }), 243 | }); 244 | 245 | return server; 246 | }, 247 | }); 248 | }); 249 | 250 | test("returns a list", async () => { 251 | await runWithTestServer({ 252 | run: async ({ client }) => { 253 | expect( 254 | await client.callTool({ 255 | arguments: { 256 | a: 1, 257 | b: 2, 258 | }, 259 | name: "add", 260 | }), 261 | ).toEqual({ 262 | content: [ 263 | { text: "a", type: "text" }, 264 | { text: "b", type: "text" }, 265 | ], 266 | }); 267 | }, 268 | server: async () => { 269 | const server = new FastMCP({ 270 | name: "Test", 271 | version: "1.0.0", 272 | }); 273 | 274 | server.addTool({ 275 | description: "Add two numbers", 276 | execute: async () => { 277 | return { 278 | content: [ 279 | { text: "a", type: "text" }, 280 | { text: "b", type: "text" }, 281 | ], 282 | }; 283 | }, 284 | name: "add", 285 | parameters: z.object({ 286 | a: z.number(), 287 | b: z.number(), 288 | }), 289 | }); 290 | 291 | return server; 292 | }, 293 | }); 294 | }); 295 | 296 | test("returns an image", async () => { 297 | await runWithTestServer({ 298 | run: async ({ client }) => { 299 | expect( 300 | await client.callTool({ 301 | arguments: { 302 | a: 1, 303 | b: 2, 304 | }, 305 | name: "add", 306 | }), 307 | ).toEqual({ 308 | content: [ 309 | { 310 | data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=", 311 | mimeType: "image/png", 312 | type: "image", 313 | }, 314 | ], 315 | }); 316 | }, 317 | server: async () => { 318 | const server = new FastMCP({ 319 | name: "Test", 320 | version: "1.0.0", 321 | }); 322 | 323 | server.addTool({ 324 | description: "Add two numbers", 325 | execute: async () => { 326 | return imageContent({ 327 | buffer: Buffer.from( 328 | "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=", 329 | "base64", 330 | ), 331 | }); 332 | }, 333 | name: "add", 334 | parameters: z.object({ 335 | a: z.number(), 336 | b: z.number(), 337 | }), 338 | }); 339 | 340 | return server; 341 | }, 342 | }); 343 | }); 344 | 345 | test("returns an audio", async () => { 346 | await runWithTestServer({ 347 | run: async ({ client }) => { 348 | expect( 349 | await client.callTool({ 350 | arguments: { 351 | a: 1, 352 | b: 2, 353 | }, 354 | name: "add", 355 | }), 356 | ).toEqual({ 357 | content: [ 358 | { 359 | data: "UklGRhwMAABXQVZFZm10IBAAAAABAAEAgD4AAIA+AAABAAgAZGF0Ya4LAACAgICAgICAgICAgICAgICAgICAgICAgICAf3hxeH+AfXZ1eHx6dnR5fYGFgoOKi42aloubq6GOjI2Op7ythXJ0eYF5aV1AOFFib32HmZSHhpCalIiYi4SRkZaLfnhxaWptb21qaWBea2BRYmZTVmFgWFNXVVVhaGdbYGhZbXh1gXZ1goeIlot1k6yxtKaOkaWhq7KonKCZoaCjoKWuqqmurK6ztrO7tbTAvru/vb68vbW6vLGqsLOfm5yal5KKhoyBeHt2dXBnbmljVlJWUEBBPDw9Mi4zKRwhIBYaGRQcHBURGB0XFxwhGxocJSstMjg6PTc6PUxVV1lWV2JqaXN0coCHhIyPjpOenqWppK6xu72yxMu9us7Pw83Wy9nY29ve6OPr6uvs6ezu6ejk6erm3uPj3dbT1sjBzdDFuMHAt7m1r7W6qaCupJOTkpWPgHqAd3JrbGlnY1peX1hTUk9PTFRKR0RFQkRBRUVEQkdBPjs9Pzo6NT04Njs+PTxAPzo/Ojk6PEA5PUJAQD04PkRCREZLUk1KT1BRUVdXU1VRV1tZV1xgXltcXF9hXl9eY2VmZmlna3J0b3F3eHyBfX+JgIWJiouTlZCTmpybnqSgnqyrqrO3srK2uL2/u7jAwMLFxsfEv8XLzcrIy83JzcrP0s3M0dTP0drY1dPR1dzc19za19XX2dnU1NjU0dXPzdHQy8rMysfGxMLBvLu3ta+sraeioJ2YlI+MioeFfX55cnJsaWVjXVlbVE5RTktHRUVAPDw3NC8uLyknKSIiJiUdHiEeGx4eHRwZHB8cHiAfHh8eHSEhISMoJyMnKisrLCszNy8yOTg9QEJFRUVITVFOTlJVWltaXmNfX2ZqZ21xb3R3eHqAhoeJkZKTlZmhpJ6kqKeur6yxtLW1trW4t6+us7axrbK2tLa6ury7u7u9u7vCwb+/vr7Ev7y9v8G8vby6vru4uLq+tri8ubi5t7W4uLW5uLKxs7G0tLGwt7Wvs7avr7O0tLW4trS4uLO1trW1trm1tLm0r7Kyr66wramsqaKlp52bmpeWl5KQkImEhIB8fXh3eHJrbW5mYGNcWFhUUE1LRENDQUI9ODcxLy8vMCsqLCgoKCgpKScoKCYoKygpKyssLi0sLi0uMDIwMTIuLzQ0Njg4Njc8ODlBQ0A/RUdGSU5RUVFUV1pdXWFjZGdpbG1vcXJ2eXh6fICAgIWIio2OkJGSlJWanJqbnZ2cn6Kkp6enq62srbCysrO1uLy4uL+/vL7CwMHAvb/Cvbq9vLm5uba2t7Sysq+urqyqqaalpqShoJ+enZuamZqXlZWTkpGSkpCNjpCMioqLioiHhoeGhYSGg4GDhoKDg4GBg4GBgoGBgoOChISChISChIWDg4WEgoSEgYODgYGCgYGAgICAgX99f398fX18e3p6e3t7enp7fHx4e3x6e3x7fHx9fX59fn1+fX19fH19fnx9fn19fX18fHx7fHx6fH18fXx8fHx7fH1+fXx+f319fn19fn1+gH9+f4B/fn+AgICAgH+AgICAgIGAgICAgH9+f4B+f35+fn58e3t8e3p5eXh4d3Z1dHRzcXBvb21sbmxqaWhlZmVjYmFfX2BfXV1cXFxaWVlaWVlYV1hYV1hYWVhZWFlaWllbXFpbXV5fX15fYWJhYmNiYWJhYWJjZGVmZ2hqbG1ub3Fxc3V3dnd6e3t8e3x+f3+AgICAgoGBgoKDhISFh4aHiYqKi4uMjYyOj4+QkZKUlZWXmJmbm52enqCioqSlpqeoqaqrrK2ur7CxsrGys7O0tbW2tba3t7i3uLe4t7a3t7i3tre2tba1tLSzsrKysbCvrq2sq6qop6alo6OioJ+dnJqZmJeWlJKSkI+OjoyLioiIh4WEg4GBgH9+fXt6eXh3d3V0c3JxcG9ubWxsamppaWhnZmVlZGRjYmNiYWBhYGBfYF9fXl5fXl1dXVxdXF1dXF1cXF1cXF1dXV5dXV5fXl9eX19gYGFgYWJhYmFiY2NiY2RjZGNkZWRlZGVmZmVmZmVmZ2dmZ2hnaGhnaGloZ2hpaWhpamlqaWpqa2pra2xtbGxtbm1ubm5vcG9wcXBxcnFycnN0c3N0dXV2d3d4eHh5ent6e3x9fn5/f4CAgIGCg4SEhYaGh4iIiYqLi4uMjY2Oj5CQkZGSk5OUlJWWlpeYl5iZmZqbm5ybnJ2cnZ6en56fn6ChoKChoqGio6KjpKOko6SjpKWkpaSkpKSlpKWkpaSlpKSlpKOkpKOko6KioaKhoaCfoJ+enp2dnJybmpmZmJeXlpWUk5STkZGQj4+OjYyLioqJh4eGhYSEgoKBgIB/fn59fHt7enl5eHd3dnZ1dHRzc3JycXBxcG9vbm5tbWxrbGxraWppaWhpaGdnZ2dmZ2ZlZmVmZWRlZGVkY2RjZGNkZGRkZGRkZGRkZGRjZGRkY2RjZGNkZWRlZGVmZWZmZ2ZnZ2doaWhpaWpra2xsbW5tbm9ub29wcXFycnNzdHV1dXZ2d3d4eXl6enp7fHx9fX5+f4CAgIGAgYGCgoOEhISFhoWGhoeIh4iJiImKiYqLiouLjI2MjI2OjY6Pj46PkI+QkZCRkJGQkZGSkZKRkpGSkZGRkZKRkpKRkpGSkZKRkpGSkZKRkpGSkZCRkZCRkI+Qj5CPkI+Pjo+OjY6Njo2MjYyLjIuMi4qLioqJiomJiImIh4iHh4aHhoaFhoWFhIWEg4SDg4KDgoKBgoGAgYCBgICAgICAf4CAf39+f35/fn1+fX59fHx9fH18e3x7fHt6e3p7ent6e3p5enl6enl6eXp5eXl4eXh5eHl4eXh5eHl4eXh5eHh3eHh4d3h4d3h3d3h4d3l4eHd4d3h3eHd4d3h3eHh4eXh5eHl4eHl4eXh5enl6eXp5enl6eXp5ent6ent6e3x7fHx9fH18fX19fn1+fX5/fn9+f4B/gH+Af4CAgICAgIGAgYCBgoGCgYKCgoKDgoOEg4OEg4SFhIWEhYSFhoWGhYaHhoeHhoeGh4iHiIiHiImIiImKiYqJiYqJiouKi4qLiouKi4qLiouKi4qLiouKi4qLi4qLiouKi4qLiomJiomIiYiJiImIh4iIh4iHhoeGhYWGhYaFhIWEg4OEg4KDgoOCgYKBgIGAgICAgH+Af39+f359fn18fX19fHx8e3t6e3p7enl6eXp5enl6enl5eXh5eHh5eHl4eXh5eHl4eHd5eHd3eHl4d3h3eHd4d3h3eHh4d3h4d3h3d3h5eHl4eXh5eHl5eXp5enl6eXp7ent6e3p7e3t7fHt8e3x8fHx9fH1+fX59fn9+f35/gH+AgICAgICAgYGAgYKBgoGCgoKDgoOEg4SEhIWFhIWFhoWGhYaGhoaHhoeGh4aHhoeIh4iHiIeHiIeIh4iHiIeIiIiHiIeIh4iHiIiHiIeIh4iHiIeIh4eIh4eIh4aHh4aHhoeGh4aHhoWGhYaFhoWFhIWEhYSFhIWEhISDhIOEg4OCg4OCg4KDgYKCgYKCgYCBgIGAgYCBgICAgICAgICAf4B/f4B/gH+Af35/fn9+f35/fn1+fn19fn1+fX59fn19fX19fH18fXx9fH18fXx9fH18fXx8fHt8e3x7fHt8e3x7fHt8e3x7fHt8e3x7fHt8e3x7fHt8e3x8e3x7fHt8e3x7fHx8fXx9fH18fX5+fX59fn9+f35+f35/gH+Af4B/gICAgICAgICAgICAgYCBgIGAgIGAgYGBgoGCgYKBgoGCgYKBgoGCgoKDgoOCg4KDgoOCg4KDgoOCg4KDgoOCg4KDgoOCg4KDgoOCg4KDgoOCg4KDgoOCg4KDgoOCg4KDgoOCg4KCgoGCgYKBgoGCgYKBgoGCgYKBgoGCgYKBgoGCgYKBgoGCgYKBgoGCgYKBgoGBgYCBgIGAgYCBgIGAgYCBgIGAgYCBgIGAgYCBgIGAgYCAgICBgIGAgYCBgIGAgYCBgIGAgYCBgExJU1RCAAAASU5GT0lDUkQMAAAAMjAwOC0wOS0yMQAASUVORwMAAAAgAAABSVNGVBYAAABTb255IFNvdW5kIEZvcmdlIDguMAAA", 360 | mimeType: "audio/wav", 361 | type: "audio", 362 | }, 363 | ], 364 | }); 365 | }, 366 | server: async () => { 367 | const server = new FastMCP({ 368 | name: "Test", 369 | version: "1.0.0", 370 | }); 371 | 372 | server.addTool({ 373 | description: "Add two numbers", 374 | execute: async () => { 375 | return audioContent({ 376 | buffer: Buffer.from( 377 | "UklGRhwMAABXQVZFZm10IBAAAAABAAEAgD4AAIA+AAABAAgAZGF0Ya4LAACAgICAgICAgICAgICAgICAgICAgICAgICAf3hxeH+AfXZ1eHx6dnR5fYGFgoOKi42aloubq6GOjI2Op7ythXJ0eYF5aV1AOFFib32HmZSHhpCalIiYi4SRkZaLfnhxaWptb21qaWBea2BRYmZTVmFgWFNXVVVhaGdbYGhZbXh1gXZ1goeIlot1k6yxtKaOkaWhq7KonKCZoaCjoKWuqqmurK6ztrO7tbTAvru/vb68vbW6vLGqsLOfm5yal5KKhoyBeHt2dXBnbmljVlJWUEBBPDw9Mi4zKRwhIBYaGRQcHBURGB0XFxwhGxocJSstMjg6PTc6PUxVV1lWV2JqaXN0coCHhIyPjpOenqWppK6xu72yxMu9us7Pw83Wy9nY29ve6OPr6uvs6ezu6ejk6erm3uPj3dbT1sjBzdDFuMHAt7m1r7W6qaCupJOTkpWPgHqAd3JrbGlnY1peX1hTUk9PTFRKR0RFQkRBRUVEQkdBPjs9Pzo6NT04Njs+PTxAPzo/Ojk6PEA5PUJAQD04PkRCREZLUk1KT1BRUVdXU1VRV1tZV1xgXltcXF9hXl9eY2VmZmlna3J0b3F3eHyBfX+JgIWJiouTlZCTmpybnqSgnqyrqrO3srK2uL2/u7jAwMLFxsfEv8XLzcrIy83JzcrP0s3M0dTP0drY1dPR1dzc19za19XX2dnU1NjU0dXPzdHQy8rMysfGxMLBvLu3ta+sraeioJ2YlI+MioeFfX55cnJsaWVjXVlbVE5RTktHRUVAPDw3NC8uLyknKSIiJiUdHiEeGx4eHRwZHB8cHiAfHh8eHSEhISMoJyMnKisrLCszNy8yOTg9QEJFRUVITVFOTlJVWltaXmNfX2ZqZ21xb3R3eHqAhoeJkZKTlZmhpJ6kqKeur6yxtLW1trW4t6+us7axrbK2tLa6ury7u7u9u7vCwb+/vr7Ev7y9v8G8vby6vru4uLq+tri8ubi5t7W4uLW5uLKxs7G0tLGwt7Wvs7avr7O0tLW4trS4uLO1trW1trm1tLm0r7Kyr66wramsqaKlp52bmpeWl5KQkImEhIB8fXh3eHJrbW5mYGNcWFhUUE1LRENDQUI9ODcxLy8vMCsqLCgoKCgpKScoKCYoKygpKyssLi0sLi0uMDIwMTIuLzQ0Njg4Njc8ODlBQ0A/RUdGSU5RUVFUV1pdXWFjZGdpbG1vcXJ2eXh6fICAgIWIio2OkJGSlJWanJqbnZ2cn6Kkp6enq62srbCysrO1uLy4uL+/vL7CwMHAvb/Cvbq9vLm5uba2t7Sysq+urqyqqaalpqShoJ+enZuamZqXlZWTkpGSkpCNjpCMioqLioiHhoeGhYSGg4GDhoKDg4GBg4GBgoGBgoOChISChISChIWDg4WEgoSEgYODgYGCgYGAgICAgX99f398fX18e3p6e3t7enp7fHx4e3x6e3x7fHx9fX59fn1+fX19fH19fnx9fn19fX18fHx7fHx6fH18fXx8fHx7fH1+fXx+f319fn19fn1+gH9+f4B/fn+AgICAgH+AgICAgIGAgICAgH9+f4B+f35+fn58e3t8e3p5eXh4d3Z1dHRzcXBvb21sbmxqaWhlZmVjYmFfX2BfXV1cXFxaWVlaWVlYV1hYV1hYWVhZWFlaWllbXFpbXV5fX15fYWJhYmNiYWJhYWJjZGVmZ2hqbG1ub3Fxc3V3dnd6e3t8e3x+f3+AgICAgoGBgoKDhISFh4aHiYqKi4uMjYyOj4+QkZKUlZWXmJmbm52enqCioqSlpqeoqaqrrK2ur7CxsrGys7O0tbW2tba3t7i3uLe4t7a3t7i3tre2tba1tLSzsrKysbCvrq2sq6qop6alo6OioJ+dnJqZmJeWlJKSkI+OjoyLioiIh4WEg4GBgH9+fXt6eXh3d3V0c3JxcG9ubWxsamppaWhnZmVlZGRjYmNiYWBhYGBfYF9fXl5fXl1dXVxdXF1dXF1cXF1cXF1dXV5dXV5fXl9eX19gYGFgYWJhYmFiY2NiY2RjZGNkZWRlZGVmZmVmZmVmZ2dmZ2hnaGhnaGloZ2hpaWhpamlqaWpqa2pra2xtbGxtbm1ubm5vcG9wcXBxcnFycnN0c3N0dXV2d3d4eHh5ent6e3x9fn5/f4CAgIGCg4SEhYaGh4iIiYqLi4uMjY2Oj5CQkZGSk5OUlJWWlpeYl5iZmZqbm5ybnJ2cnZ6en56fn6ChoKChoqGio6KjpKOko6SjpKWkpaSkpKSlpKWkpaSlpKSlpKOkpKOko6KioaKhoaCfoJ+enp2dnJybmpmZmJeXlpWUk5STkZGQj4+OjYyLioqJh4eGhYSEgoKBgIB/fn59fHt7enl5eHd3dnZ1dHRzc3JycXBxcG9vbm5tbWxrbGxraWppaWhpaGdnZ2dmZ2ZlZmVmZWRlZGVkY2RjZGNkZGRkZGRkZGRkZGRjZGRkY2RjZGNkZWRlZGVmZWZmZ2ZnZ2doaWhpaWpra2xsbW5tbm9ub29wcXFycnNzdHV1dXZ2d3d4eXl6enp7fHx9fX5+f4CAgIGAgYGCgoOEhISFhoWGhoeIh4iJiImKiYqLiouLjI2MjI2OjY6Pj46PkI+QkZCRkJGQkZGSkZKRkpGSkZGRkZKRkpKRkpGSkZKRkpGSkZKRkpGSkZCRkZCRkI+Qj5CPkI+Pjo+OjY6Njo2MjYyLjIuMi4qLioqJiomJiImIh4iHh4aHhoaFhoWFhIWEg4SDg4KDgoKBgoGAgYCBgICAgICAf4CAf39+f35/fn1+fX59fHx9fH18e3x7fHt6e3p7ent6e3p5enl6enl6eXp5eXl4eXh5eHl4eXh5eHl4eXh5eHh3eHh4d3h4d3h3d3h4d3l4eHd4d3h3eHd4d3h3eHh4eXh5eHl4eHl4eXh5enl6eXp5enl6eXp5ent6ent6e3x7fHx9fH18fX19fn1+fX5/fn9+f4B/gH+Af4CAgICAgIGAgYCBgoGCgYKCgoKDgoOEg4OEg4SFhIWEhYSFhoWGhYaHhoeHhoeGh4iHiIiHiImIiImKiYqJiYqJiouKi4qLiouKi4qLiouKi4qLiouKi4qLi4qLiouKi4qLiomJiomIiYiJiImIh4iIh4iHhoeGhYWGhYaFhIWEg4OEg4KDgoOCgYKBgIGAgICAgH+Af39+f359fn18fX19fHx8e3t6e3p7enl6eXp5enl6enl5eXh5eHh5eHl4eXh5eHl4eHd5eHd3eHl4d3h3eHd4d3h3eHh4d3h4d3h3d3h5eHl4eXh5eHl5eXp5enl6eXp7ent6e3p7e3t7fHt8e3x8fHx9fH1+fX59fn9+f35/gH+AgICAgICAgYGAgYKBgoGCgoKDgoOEg4SEhIWFhIWFhoWGhYaGhoaHhoeGh4aHhoeIh4iHiIeHiIeIh4iHiIeIiIiHiIeIh4iHiIiHiIeIh4iHiIeIh4eIh4eIh4aHh4aHhoeGh4aHhoWGhYaFhoWFhIWEhYSFhIWEhISDhIOEg4OCg4OCg4KDgYKCgYKCgYCBgIGAgYCBgICAgICAgICAf4B/f4B/gH+Af35/fn9+f35/fn1+fn19fn1+fX59fn19fX19fH18fXx9fH18fXx9fH18fXx8fHt8e3x7fHt8e3x7fHt8e3x7fHt8e3x7fHt8e3x7fHt8e3x8e3x7fHt8e3x7fHx8fXx9fH18fX5+fX59fn9+f35+f35/gH+Af4B/gICAgICAgICAgICAgYCBgIGAgIGAgYGBgoGCgYKBgoGCgYKBgoGCgoKDgoOCg4KDgoOCg4KDgoOCg4KDgoOCg4KDgoOCg4KDgoOCg4KDgoOCg4KDgoOCg4KDgoOCg4KDgoOCg4KCgoGCgYKBgoGCgYKBgoGCgYKBgoGCgYKBgoGCgYKBgoGCgYKBgoGCgYKBgoGBgYCBgIGAgYCBgIGAgYCBgIGAgYCBgIGAgYCBgIGAgYCAgICBgIGAgYCBgIGAgYCBgIGAgYCBgExJU1RCAAAASU5GT0lDUkQMAAAAMjAwOC0wOS0yMQAASUVORwMAAAAgAAABSVNGVBYAAABTb255IFNvdW5kIEZvcmdlIDguMAAA", 378 | "base64", 379 | ), 380 | }); 381 | }, 382 | name: "add", 383 | parameters: z.object({ 384 | a: z.number(), 385 | b: z.number(), 386 | }), 387 | }); 388 | 389 | return server; 390 | }, 391 | }); 392 | }); 393 | 394 | test("handles UserError errors", async () => { 395 | await runWithTestServer({ 396 | run: async ({ client }) => { 397 | expect( 398 | await client.callTool({ 399 | arguments: { 400 | a: 1, 401 | b: 2, 402 | }, 403 | name: "add", 404 | }), 405 | ).toEqual({ 406 | content: [{ text: "Something went wrong", type: "text" }], 407 | isError: true, 408 | }); 409 | }, 410 | server: async () => { 411 | const server = new FastMCP({ 412 | name: "Test", 413 | version: "1.0.0", 414 | }); 415 | 416 | server.addTool({ 417 | description: "Add two numbers", 418 | execute: async () => { 419 | throw new UserError("Something went wrong"); 420 | }, 421 | name: "add", 422 | parameters: z.object({ 423 | a: z.number(), 424 | b: z.number(), 425 | }), 426 | }); 427 | 428 | return server; 429 | }, 430 | }); 431 | }); 432 | 433 | test("calling an unknown tool throws McpError with MethodNotFound code", async () => { 434 | await runWithTestServer({ 435 | run: async ({ client }) => { 436 | try { 437 | await client.callTool({ 438 | arguments: { 439 | a: 1, 440 | b: 2, 441 | }, 442 | name: "add", 443 | }); 444 | } catch (error) { 445 | expect(error).toBeInstanceOf(McpError); 446 | 447 | // @ts-expect-error - we know that error is an McpError 448 | expect(error.code).toBe(ErrorCode.MethodNotFound); 449 | } 450 | }, 451 | server: async () => { 452 | const server = new FastMCP({ 453 | name: "Test", 454 | version: "1.0.0", 455 | }); 456 | 457 | return server; 458 | }, 459 | }); 460 | }); 461 | 462 | test("tracks tool progress", async () => { 463 | await runWithTestServer({ 464 | run: async ({ client }) => { 465 | const onProgress = vi.fn(); 466 | 467 | await client.callTool( 468 | { 469 | arguments: { 470 | a: 1, 471 | b: 2, 472 | }, 473 | name: "add", 474 | }, 475 | undefined, 476 | { 477 | onprogress: onProgress, 478 | }, 479 | ); 480 | 481 | expect(onProgress).toHaveBeenCalledTimes(1); 482 | expect(onProgress).toHaveBeenCalledWith({ 483 | progress: 0, 484 | total: 10, 485 | }); 486 | }, 487 | server: async () => { 488 | const server = new FastMCP({ 489 | name: "Test", 490 | version: "1.0.0", 491 | }); 492 | 493 | server.addTool({ 494 | description: "Add two numbers", 495 | execute: async (args, { reportProgress }) => { 496 | reportProgress({ 497 | progress: 0, 498 | total: 10, 499 | }); 500 | 501 | await delay(100); 502 | 503 | return String(args.a + args.b); 504 | }, 505 | name: "add", 506 | parameters: z.object({ 507 | a: z.number(), 508 | b: z.number(), 509 | }), 510 | }); 511 | 512 | return server; 513 | }, 514 | }); 515 | }); 516 | 517 | test("sets logging levels", async () => { 518 | await runWithTestServer({ 519 | run: async ({ client, session }) => { 520 | await client.setLoggingLevel("debug"); 521 | 522 | expect(session.loggingLevel).toBe("debug"); 523 | 524 | await client.setLoggingLevel("info"); 525 | 526 | expect(session.loggingLevel).toBe("info"); 527 | }, 528 | }); 529 | }); 530 | 531 | test("handles tool timeout", async () => { 532 | await runWithTestServer({ 533 | run: async ({ client }) => { 534 | const result = await client.callTool({ 535 | arguments: { 536 | a: 1500, 537 | b: 2, 538 | }, 539 | name: "add", 540 | }); 541 | 542 | expect(result.isError).toBe(true); 543 | 544 | const result_typed = result as ContentResult; 545 | 546 | expect(Array.isArray(result_typed.content)).toBe(true); 547 | expect(result_typed.content.length).toBe(1); 548 | 549 | const firstItem = result_typed.content[0] as TextContent; 550 | 551 | expect(firstItem.type).toBe("text"); 552 | expect(firstItem.text).toBeDefined(); 553 | expect(firstItem.text).toContain("timed out"); 554 | }, 555 | server: async () => { 556 | const server = new FastMCP({ 557 | name: "Test", 558 | version: "1.0.0", 559 | }); 560 | 561 | server.addTool({ 562 | description: "Add two numbers with potential timeout", 563 | execute: async (args) => { 564 | console.log(`Adding ${args.a} and ${args.b}`); 565 | 566 | if (args.a > 1000 || args.b > 1000) { 567 | await new Promise((resolve) => setTimeout(resolve, 3000)); 568 | } 569 | 570 | return String(args.a + args.b); 571 | }, 572 | name: "add", 573 | parameters: z.object({ 574 | a: z.number(), 575 | b: z.number(), 576 | }), 577 | timeoutMs: 1000, 578 | }); 579 | 580 | return server; 581 | }, 582 | }); 583 | }); 584 | 585 | test("sends logging messages to the client", async () => { 586 | await runWithTestServer({ 587 | run: async ({ client }) => { 588 | const onLog = vi.fn(); 589 | 590 | client.setNotificationHandler( 591 | LoggingMessageNotificationSchema, 592 | (message) => { 593 | if (message.method === "notifications/message") { 594 | onLog({ 595 | level: message.params.level, 596 | ...(message.params.data ?? {}), 597 | }); 598 | } 599 | }, 600 | ); 601 | 602 | await client.callTool({ 603 | arguments: { 604 | a: 1, 605 | b: 2, 606 | }, 607 | name: "add", 608 | }); 609 | 610 | expect(onLog).toHaveBeenCalledTimes(4); 611 | expect(onLog).toHaveBeenNthCalledWith(1, { 612 | context: { 613 | foo: "bar", 614 | }, 615 | level: "debug", 616 | message: "debug message", 617 | }); 618 | expect(onLog).toHaveBeenNthCalledWith(2, { 619 | level: "error", 620 | message: "error message", 621 | }); 622 | expect(onLog).toHaveBeenNthCalledWith(3, { 623 | level: "info", 624 | message: "info message", 625 | }); 626 | expect(onLog).toHaveBeenNthCalledWith(4, { 627 | level: "warning", 628 | message: "warn message", 629 | }); 630 | }, 631 | server: async () => { 632 | const server = new FastMCP({ 633 | name: "Test", 634 | version: "1.0.0", 635 | }); 636 | 637 | server.addTool({ 638 | description: "Add two numbers", 639 | execute: async (args, { log }) => { 640 | log.debug("debug message", { 641 | foo: "bar", 642 | }); 643 | log.error("error message"); 644 | log.info("info message"); 645 | log.warn("warn message"); 646 | 647 | return String(args.a + args.b); 648 | }, 649 | name: "add", 650 | parameters: z.object({ 651 | a: z.number(), 652 | b: z.number(), 653 | }), 654 | }); 655 | 656 | return server; 657 | }, 658 | }); 659 | }); 660 | 661 | test("adds resources", async () => { 662 | await runWithTestServer({ 663 | run: async ({ client }) => { 664 | expect(await client.listResources()).toEqual({ 665 | resources: [ 666 | { 667 | mimeType: "text/plain", 668 | name: "Application Logs", 669 | uri: "file:///logs/app.log", 670 | }, 671 | ], 672 | }); 673 | }, 674 | server: async () => { 675 | const server = new FastMCP({ 676 | name: "Test", 677 | version: "1.0.0", 678 | }); 679 | 680 | server.addResource({ 681 | async load() { 682 | return { 683 | text: "Example log content", 684 | }; 685 | }, 686 | mimeType: "text/plain", 687 | name: "Application Logs", 688 | uri: "file:///logs/app.log", 689 | }); 690 | 691 | return server; 692 | }, 693 | }); 694 | }); 695 | 696 | test("clients reads a resource", async () => { 697 | await runWithTestServer({ 698 | run: async ({ client }) => { 699 | expect( 700 | await client.readResource({ 701 | uri: "file:///logs/app.log", 702 | }), 703 | ).toEqual({ 704 | contents: [ 705 | { 706 | mimeType: "text/plain", 707 | name: "Application Logs", 708 | text: "Example log content", 709 | uri: "file:///logs/app.log", 710 | }, 711 | ], 712 | }); 713 | }, 714 | server: async () => { 715 | const server = new FastMCP({ 716 | name: "Test", 717 | version: "1.0.0", 718 | }); 719 | 720 | server.addResource({ 721 | async load() { 722 | return { 723 | text: "Example log content", 724 | }; 725 | }, 726 | mimeType: "text/plain", 727 | name: "Application Logs", 728 | uri: "file:///logs/app.log", 729 | }); 730 | 731 | return server; 732 | }, 733 | }); 734 | }); 735 | 736 | test("clients reads a resource that returns multiple resources", async () => { 737 | await runWithTestServer({ 738 | run: async ({ client }) => { 739 | expect( 740 | await client.readResource({ 741 | uri: "file:///logs/app.log", 742 | }), 743 | ).toEqual({ 744 | contents: [ 745 | { 746 | mimeType: "text/plain", 747 | name: "Application Logs", 748 | text: "a", 749 | uri: "file:///logs/app.log", 750 | }, 751 | { 752 | mimeType: "text/plain", 753 | name: "Application Logs", 754 | text: "b", 755 | uri: "file:///logs/app.log", 756 | }, 757 | ], 758 | }); 759 | }, 760 | server: async () => { 761 | const server = new FastMCP({ 762 | name: "Test", 763 | version: "1.0.0", 764 | }); 765 | 766 | server.addResource({ 767 | async load() { 768 | return [ 769 | { 770 | text: "a", 771 | }, 772 | { 773 | text: "b", 774 | }, 775 | ]; 776 | }, 777 | mimeType: "text/plain", 778 | name: "Application Logs", 779 | uri: "file:///logs/app.log", 780 | }); 781 | 782 | return server; 783 | }, 784 | }); 785 | }); 786 | 787 | test("embedded resources work in tools", async () => { 788 | await runWithTestServer({ 789 | run: async ({ client }) => { 790 | expect( 791 | await client.callTool({ 792 | arguments: { 793 | userId: "123", 794 | }, 795 | name: "get_user_profile", 796 | }), 797 | ).toEqual({ 798 | content: [ 799 | { 800 | resource: { 801 | mimeType: "application/json", 802 | text: '{"id":"123","name":"User","email":"user@example.com"}', 803 | uri: "user://profile/123", 804 | }, 805 | type: "resource", 806 | }, 807 | ], 808 | }); 809 | }, 810 | 811 | server: async () => { 812 | const server = new FastMCP({ 813 | name: "Test", 814 | version: "1.0.0", 815 | }); 816 | 817 | server.addResourceTemplate({ 818 | arguments: [ 819 | { 820 | name: "userId", 821 | required: true, 822 | }, 823 | ], 824 | async load(args) { 825 | return { 826 | text: `{"id":"${args.userId}","name":"User","email":"user@example.com"}`, 827 | }; 828 | }, 829 | mimeType: "application/json", 830 | name: "User Profile", 831 | uriTemplate: "user://profile/{userId}", 832 | }); 833 | 834 | server.addTool({ 835 | description: "Get user profile data", 836 | execute: async (args) => { 837 | return { 838 | content: [ 839 | { 840 | resource: await server.embedded( 841 | `user://profile/${args.userId}`, 842 | ), 843 | type: "resource", 844 | }, 845 | ], 846 | }; 847 | }, 848 | name: "get_user_profile", 849 | parameters: z.object({ 850 | userId: z.string(), 851 | }), 852 | }); 853 | 854 | return server; 855 | }, 856 | }); 857 | }); 858 | 859 | test("embedded resources work with direct resources", async () => { 860 | await runWithTestServer({ 861 | run: async ({ client }) => { 862 | expect( 863 | await client.callTool({ 864 | arguments: {}, 865 | name: "get_logs", 866 | }), 867 | ).toEqual({ 868 | content: [ 869 | { 870 | resource: { 871 | mimeType: "text/plain", 872 | text: "Example log content", 873 | uri: "file:///logs/app.log", 874 | }, 875 | type: "resource", 876 | }, 877 | ], 878 | }); 879 | }, 880 | 881 | server: async () => { 882 | const server = new FastMCP({ 883 | name: "Test", 884 | version: "1.0.0", 885 | }); 886 | 887 | server.addResource({ 888 | async load() { 889 | return { 890 | text: "Example log content", 891 | }; 892 | }, 893 | mimeType: "text/plain", 894 | name: "Application Logs", 895 | uri: "file:///logs/app.log", 896 | }); 897 | 898 | server.addTool({ 899 | description: "Get application logs", 900 | execute: async () => { 901 | return { 902 | content: [ 903 | { 904 | resource: await server.embedded("file:///logs/app.log"), 905 | type: "resource", 906 | }, 907 | ], 908 | }; 909 | }, 910 | name: "get_logs", 911 | parameters: z.object({}), 912 | }); 913 | 914 | return server; 915 | }, 916 | }); 917 | }); 918 | 919 | test("adds prompts", async () => { 920 | await runWithTestServer({ 921 | run: async ({ client }) => { 922 | expect( 923 | await client.getPrompt({ 924 | arguments: { 925 | changes: "foo", 926 | }, 927 | name: "git-commit", 928 | }), 929 | ).toEqual({ 930 | description: "Generate a Git commit message", 931 | messages: [ 932 | { 933 | content: { 934 | text: "Generate a concise but descriptive commit message for these changes:\n\nfoo", 935 | type: "text", 936 | }, 937 | role: "user", 938 | }, 939 | ], 940 | }); 941 | 942 | expect(await client.listPrompts()).toEqual({ 943 | prompts: [ 944 | { 945 | arguments: [ 946 | { 947 | description: "Git diff or description of changes", 948 | name: "changes", 949 | required: true, 950 | }, 951 | ], 952 | description: "Generate a Git commit message", 953 | name: "git-commit", 954 | }, 955 | ], 956 | }); 957 | }, 958 | server: async () => { 959 | const server = new FastMCP({ 960 | name: "Test", 961 | version: "1.0.0", 962 | }); 963 | 964 | server.addPrompt({ 965 | arguments: [ 966 | { 967 | description: "Git diff or description of changes", 968 | name: "changes", 969 | required: true, 970 | }, 971 | ], 972 | description: "Generate a Git commit message", 973 | load: async (args) => { 974 | return `Generate a concise but descriptive commit message for these changes:\n\n${args.changes}`; 975 | }, 976 | name: "git-commit", 977 | }); 978 | 979 | return server; 980 | }, 981 | }); 982 | }); 983 | 984 | test("uses events to notify server of client connect/disconnect", async () => { 985 | const port = await getRandomPort(); 986 | 987 | const server = new FastMCP({ 988 | name: "Test", 989 | version: "1.0.0", 990 | }); 991 | 992 | const onConnect = vi.fn(); 993 | const onDisconnect = vi.fn(); 994 | 995 | server.on("connect", onConnect); 996 | server.on("disconnect", onDisconnect); 997 | 998 | await server.start({ 999 | httpStream: { 1000 | port, 1001 | }, 1002 | transportType: "httpStream", 1003 | }); 1004 | 1005 | const client = new Client( 1006 | { 1007 | name: "example-client", 1008 | version: "1.0.0", 1009 | }, 1010 | { 1011 | capabilities: {}, 1012 | }, 1013 | ); 1014 | 1015 | const transport = new SSEClientTransport( 1016 | new URL(`http://localhost:${port}/sse`), 1017 | ); 1018 | 1019 | await client.connect(transport); 1020 | 1021 | await delay(100); 1022 | 1023 | expect(onConnect).toHaveBeenCalledTimes(1); 1024 | expect(onDisconnect).toHaveBeenCalledTimes(0); 1025 | 1026 | expect(server.sessions).toEqual([expect.any(FastMCPSession)]); 1027 | 1028 | await client.close(); 1029 | 1030 | await delay(100); 1031 | 1032 | expect(onConnect).toHaveBeenCalledTimes(1); 1033 | expect(onDisconnect).toHaveBeenCalledTimes(1); 1034 | 1035 | await server.stop(); 1036 | }); 1037 | 1038 | test("handles multiple clients", async () => { 1039 | const port = await getRandomPort(); 1040 | 1041 | const server = new FastMCP({ 1042 | name: "Test", 1043 | version: "1.0.0", 1044 | }); 1045 | 1046 | await server.start({ 1047 | httpStream: { 1048 | port, 1049 | }, 1050 | transportType: "httpStream", 1051 | }); 1052 | 1053 | const client1 = new Client( 1054 | { 1055 | name: "example-client", 1056 | version: "1.0.0", 1057 | }, 1058 | { 1059 | capabilities: {}, 1060 | }, 1061 | ); 1062 | 1063 | const transport1 = new SSEClientTransport( 1064 | new URL(`http://localhost:${port}/sse`), 1065 | ); 1066 | 1067 | await client1.connect(transport1); 1068 | 1069 | const client2 = new Client( 1070 | { 1071 | name: "example-client", 1072 | version: "1.0.0", 1073 | }, 1074 | { 1075 | capabilities: {}, 1076 | }, 1077 | ); 1078 | 1079 | const transport2 = new SSEClientTransport( 1080 | new URL(`http://localhost:${port}/sse`), 1081 | ); 1082 | 1083 | await client2.connect(transport2); 1084 | 1085 | await delay(100); 1086 | 1087 | expect(server.sessions).toEqual([ 1088 | expect.any(FastMCPSession), 1089 | expect.any(FastMCPSession), 1090 | ]); 1091 | 1092 | await server.stop(); 1093 | }); 1094 | 1095 | test("session knows about client capabilities", async () => { 1096 | await runWithTestServer({ 1097 | client: async () => { 1098 | const client = new Client( 1099 | { 1100 | name: "example-client", 1101 | version: "1.0.0", 1102 | }, 1103 | { 1104 | capabilities: { 1105 | roots: { 1106 | listChanged: true, 1107 | }, 1108 | }, 1109 | }, 1110 | ); 1111 | 1112 | client.setRequestHandler(ListRootsRequestSchema, () => { 1113 | return { 1114 | roots: [ 1115 | { 1116 | name: "Frontend Repository", 1117 | uri: "file:///home/user/projects/frontend", 1118 | }, 1119 | ], 1120 | }; 1121 | }); 1122 | 1123 | return client; 1124 | }, 1125 | run: async ({ session }) => { 1126 | expect(session.clientCapabilities).toEqual({ 1127 | roots: { 1128 | listChanged: true, 1129 | }, 1130 | }); 1131 | }, 1132 | }); 1133 | }); 1134 | 1135 | test("session knows about roots", async () => { 1136 | await runWithTestServer({ 1137 | client: async () => { 1138 | const client = new Client( 1139 | { 1140 | name: "example-client", 1141 | version: "1.0.0", 1142 | }, 1143 | { 1144 | capabilities: { 1145 | roots: { 1146 | listChanged: true, 1147 | }, 1148 | }, 1149 | }, 1150 | ); 1151 | 1152 | client.setRequestHandler(ListRootsRequestSchema, () => { 1153 | return { 1154 | roots: [ 1155 | { 1156 | name: "Frontend Repository", 1157 | uri: "file:///home/user/projects/frontend", 1158 | }, 1159 | ], 1160 | }; 1161 | }); 1162 | 1163 | return client; 1164 | }, 1165 | run: async ({ session }) => { 1166 | expect(session.roots).toEqual([ 1167 | { 1168 | name: "Frontend Repository", 1169 | uri: "file:///home/user/projects/frontend", 1170 | }, 1171 | ]); 1172 | }, 1173 | }); 1174 | }); 1175 | 1176 | test("session listens to roots changes", async () => { 1177 | const clientRoots: Root[] = [ 1178 | { 1179 | name: "Frontend Repository", 1180 | uri: "file:///home/user/projects/frontend", 1181 | }, 1182 | ]; 1183 | 1184 | await runWithTestServer({ 1185 | client: async () => { 1186 | const client = new Client( 1187 | { 1188 | name: "example-client", 1189 | version: "1.0.0", 1190 | }, 1191 | { 1192 | capabilities: { 1193 | roots: { 1194 | listChanged: true, 1195 | }, 1196 | }, 1197 | }, 1198 | ); 1199 | 1200 | client.setRequestHandler(ListRootsRequestSchema, () => { 1201 | return { 1202 | roots: clientRoots, 1203 | }; 1204 | }); 1205 | 1206 | return client; 1207 | }, 1208 | run: async ({ client, session }) => { 1209 | expect(session.roots).toEqual([ 1210 | { 1211 | name: "Frontend Repository", 1212 | uri: "file:///home/user/projects/frontend", 1213 | }, 1214 | ]); 1215 | 1216 | clientRoots.push({ 1217 | name: "Backend Repository", 1218 | uri: "file:///home/user/projects/backend", 1219 | }); 1220 | 1221 | await client.sendRootsListChanged(); 1222 | 1223 | const onRootsChanged = vi.fn(); 1224 | 1225 | session.on("rootsChanged", onRootsChanged); 1226 | 1227 | await delay(100); 1228 | 1229 | expect(session.roots).toEqual([ 1230 | { 1231 | name: "Frontend Repository", 1232 | uri: "file:///home/user/projects/frontend", 1233 | }, 1234 | { 1235 | name: "Backend Repository", 1236 | uri: "file:///home/user/projects/backend", 1237 | }, 1238 | ]); 1239 | 1240 | expect(onRootsChanged).toHaveBeenCalledTimes(1); 1241 | expect(onRootsChanged).toHaveBeenCalledWith({ 1242 | roots: [ 1243 | { 1244 | name: "Frontend Repository", 1245 | uri: "file:///home/user/projects/frontend", 1246 | }, 1247 | { 1248 | name: "Backend Repository", 1249 | uri: "file:///home/user/projects/backend", 1250 | }, 1251 | ], 1252 | }); 1253 | }, 1254 | }); 1255 | }); 1256 | 1257 | test("session sends pings to the client", async () => { 1258 | await runWithTestServer({ 1259 | run: async ({ client }) => { 1260 | const onPing = vi.fn().mockReturnValue({}); 1261 | 1262 | client.setRequestHandler(PingRequestSchema, onPing); 1263 | 1264 | await delay(2000); 1265 | 1266 | expect(onPing.mock.calls.length).toBeGreaterThanOrEqual(1); 1267 | expect(onPing.mock.calls.length).toBeLessThanOrEqual(3); 1268 | }, 1269 | server: async () => { 1270 | const server = new FastMCP({ 1271 | name: "Test", 1272 | ping: { 1273 | enabled: true, 1274 | intervalMs: 1000, 1275 | }, 1276 | version: "1.0.0", 1277 | }); 1278 | return server; 1279 | }, 1280 | }); 1281 | }); 1282 | 1283 | test("completes prompt arguments", async () => { 1284 | await runWithTestServer({ 1285 | run: async ({ client }) => { 1286 | const response = await client.complete({ 1287 | argument: { 1288 | name: "name", 1289 | value: "Germ", 1290 | }, 1291 | ref: { 1292 | name: "countryPoem", 1293 | type: "ref/prompt", 1294 | }, 1295 | }); 1296 | 1297 | expect(response).toEqual({ 1298 | completion: { 1299 | values: ["Germany"], 1300 | }, 1301 | }); 1302 | }, 1303 | server: async () => { 1304 | const server = new FastMCP({ 1305 | name: "Test", 1306 | version: "1.0.0", 1307 | }); 1308 | 1309 | server.addPrompt({ 1310 | arguments: [ 1311 | { 1312 | complete: async (value) => { 1313 | if (value === "Germ") { 1314 | return { 1315 | values: ["Germany"], 1316 | }; 1317 | } 1318 | 1319 | return { 1320 | values: [], 1321 | }; 1322 | }, 1323 | description: "Name of the country", 1324 | name: "name", 1325 | required: true, 1326 | }, 1327 | ], 1328 | description: "Writes a poem about a country", 1329 | load: async ({ name }) => { 1330 | return `Hello, ${name}!`; 1331 | }, 1332 | name: "countryPoem", 1333 | }); 1334 | 1335 | return server; 1336 | }, 1337 | }); 1338 | }); 1339 | 1340 | test("adds automatic prompt argument completion when enum is provided", async () => { 1341 | await runWithTestServer({ 1342 | run: async ({ client }) => { 1343 | const response = await client.complete({ 1344 | argument: { 1345 | name: "name", 1346 | value: "Germ", 1347 | }, 1348 | ref: { 1349 | name: "countryPoem", 1350 | type: "ref/prompt", 1351 | }, 1352 | }); 1353 | 1354 | expect(response).toEqual({ 1355 | completion: { 1356 | total: 1, 1357 | values: ["Germany"], 1358 | }, 1359 | }); 1360 | }, 1361 | server: async () => { 1362 | const server = new FastMCP({ 1363 | name: "Test", 1364 | version: "1.0.0", 1365 | }); 1366 | 1367 | server.addPrompt({ 1368 | arguments: [ 1369 | { 1370 | description: "Name of the country", 1371 | enum: ["Germany", "France", "Italy"], 1372 | name: "name", 1373 | required: true, 1374 | }, 1375 | ], 1376 | description: "Writes a poem about a country", 1377 | load: async ({ name }) => { 1378 | return `Hello, ${name}!`; 1379 | }, 1380 | name: "countryPoem", 1381 | }); 1382 | 1383 | return server; 1384 | }, 1385 | }); 1386 | }); 1387 | 1388 | test("completes template resource arguments", async () => { 1389 | await runWithTestServer({ 1390 | run: async ({ client }) => { 1391 | const response = await client.complete({ 1392 | argument: { 1393 | name: "issueId", 1394 | value: "123", 1395 | }, 1396 | ref: { 1397 | type: "ref/resource", 1398 | uri: "issue:///{issueId}", 1399 | }, 1400 | }); 1401 | 1402 | expect(response).toEqual({ 1403 | completion: { 1404 | values: ["123456"], 1405 | }, 1406 | }); 1407 | }, 1408 | server: async () => { 1409 | const server = new FastMCP({ 1410 | name: "Test", 1411 | version: "1.0.0", 1412 | }); 1413 | 1414 | server.addResourceTemplate({ 1415 | arguments: [ 1416 | { 1417 | complete: async (value) => { 1418 | if (value === "123") { 1419 | return { 1420 | values: ["123456"], 1421 | }; 1422 | } 1423 | 1424 | return { 1425 | values: [], 1426 | }; 1427 | }, 1428 | description: "ID of the issue", 1429 | name: "issueId", 1430 | }, 1431 | ], 1432 | load: async ({ issueId }) => { 1433 | return { 1434 | text: `Issue ${issueId}`, 1435 | }; 1436 | }, 1437 | mimeType: "text/plain", 1438 | name: "Issue", 1439 | uriTemplate: "issue:///{issueId}", 1440 | }); 1441 | 1442 | return server; 1443 | }, 1444 | }); 1445 | }); 1446 | 1447 | test("lists resource templates", async () => { 1448 | await runWithTestServer({ 1449 | run: async ({ client }) => { 1450 | expect(await client.listResourceTemplates()).toEqual({ 1451 | resourceTemplates: [ 1452 | { 1453 | name: "Application Logs", 1454 | uriTemplate: "file:///logs/{name}.log", 1455 | }, 1456 | ], 1457 | }); 1458 | }, 1459 | server: async () => { 1460 | const server = new FastMCP({ 1461 | name: "Test", 1462 | version: "1.0.0", 1463 | }); 1464 | 1465 | server.addResourceTemplate({ 1466 | arguments: [ 1467 | { 1468 | description: "Name of the log", 1469 | name: "name", 1470 | required: true, 1471 | }, 1472 | ], 1473 | load: async ({ name }) => { 1474 | return { 1475 | text: `Example log content for ${name}`, 1476 | }; 1477 | }, 1478 | mimeType: "text/plain", 1479 | name: "Application Logs", 1480 | uriTemplate: "file:///logs/{name}.log", 1481 | }); 1482 | 1483 | return server; 1484 | }, 1485 | }); 1486 | }); 1487 | 1488 | test("clients reads a resource accessed via a resource template", async () => { 1489 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 1490 | const loadSpy = vi.fn((_args) => { 1491 | return { 1492 | text: "Example log content", 1493 | }; 1494 | }); 1495 | 1496 | await runWithTestServer({ 1497 | run: async ({ client }) => { 1498 | expect( 1499 | await client.readResource({ 1500 | uri: "file:///logs/app.log", 1501 | }), 1502 | ).toEqual({ 1503 | contents: [ 1504 | { 1505 | mimeType: "text/plain", 1506 | name: "Application Logs", 1507 | text: "Example log content", 1508 | uri: "file:///logs/app.log", 1509 | }, 1510 | ], 1511 | }); 1512 | 1513 | expect(loadSpy).toHaveBeenCalledWith({ 1514 | name: "app", 1515 | }); 1516 | }, 1517 | server: async () => { 1518 | const server = new FastMCP({ 1519 | name: "Test", 1520 | version: "1.0.0", 1521 | }); 1522 | 1523 | server.addResourceTemplate({ 1524 | arguments: [ 1525 | { 1526 | description: "Name of the log", 1527 | name: "name", 1528 | }, 1529 | ], 1530 | async load(args) { 1531 | return loadSpy(args); 1532 | }, 1533 | mimeType: "text/plain", 1534 | name: "Application Logs", 1535 | uriTemplate: "file:///logs/{name}.log", 1536 | }); 1537 | 1538 | return server; 1539 | }, 1540 | }); 1541 | }); 1542 | 1543 | test("makes a sampling request", async () => { 1544 | const onMessageRequest = vi.fn(() => { 1545 | return { 1546 | content: { 1547 | text: "The files are in the current directory.", 1548 | type: "text", 1549 | }, 1550 | model: "gpt-3.5-turbo", 1551 | role: "assistant", 1552 | }; 1553 | }); 1554 | 1555 | await runWithTestServer({ 1556 | client: async () => { 1557 | const client = new Client( 1558 | { 1559 | name: "example-client", 1560 | version: "1.0.0", 1561 | }, 1562 | { 1563 | capabilities: { 1564 | sampling: {}, 1565 | }, 1566 | }, 1567 | ); 1568 | return client; 1569 | }, 1570 | run: async ({ client, session }) => { 1571 | client.setRequestHandler(CreateMessageRequestSchema, onMessageRequest); 1572 | 1573 | const response = await session.requestSampling({ 1574 | includeContext: "thisServer", 1575 | maxTokens: 100, 1576 | messages: [ 1577 | { 1578 | content: { 1579 | text: "What files are in the current directory?", 1580 | type: "text", 1581 | }, 1582 | role: "user", 1583 | }, 1584 | ], 1585 | systemPrompt: "You are a helpful file system assistant.", 1586 | }); 1587 | 1588 | expect(response).toEqual({ 1589 | content: { 1590 | text: "The files are in the current directory.", 1591 | type: "text", 1592 | }, 1593 | model: "gpt-3.5-turbo", 1594 | role: "assistant", 1595 | }); 1596 | 1597 | expect(onMessageRequest).toHaveBeenCalledTimes(1); 1598 | }, 1599 | }); 1600 | }); 1601 | 1602 | test("throws ErrorCode.InvalidParams if tool parameters do not match zod schema", async () => { 1603 | await runWithTestServer({ 1604 | run: async ({ client }) => { 1605 | try { 1606 | await client.callTool({ 1607 | arguments: { 1608 | a: 1, 1609 | b: "invalid", 1610 | }, 1611 | name: "add", 1612 | }); 1613 | } catch (error) { 1614 | expect(error).toBeInstanceOf(McpError); 1615 | 1616 | // @ts-expect-error - we know that error is an McpError 1617 | expect(error.code).toBe(ErrorCode.InvalidParams); 1618 | 1619 | // @ts-expect-error - we know that error is an McpError 1620 | expect(error.message).toBe( 1621 | "MCP error -32602: MCP error -32602: Tool 'add' parameter validation failed: b: Expected number, received string", 1622 | ); 1623 | } 1624 | }, 1625 | server: async () => { 1626 | const server = new FastMCP({ 1627 | name: "Test", 1628 | version: "1.0.0", 1629 | }); 1630 | 1631 | server.addTool({ 1632 | description: "Add two numbers", 1633 | execute: async (args) => { 1634 | return String(args.a + args.b); 1635 | }, 1636 | name: "add", 1637 | parameters: z.object({ 1638 | a: z.number(), 1639 | b: z.number(), 1640 | }), 1641 | }); 1642 | 1643 | return server; 1644 | }, 1645 | }); 1646 | }); 1647 | 1648 | test("server remains usable after InvalidParams error", async () => { 1649 | await runWithTestServer({ 1650 | run: async ({ client }) => { 1651 | try { 1652 | await client.callTool({ 1653 | arguments: { 1654 | a: 1, 1655 | b: "invalid", 1656 | }, 1657 | name: "add", 1658 | }); 1659 | } catch (error) { 1660 | expect(error).toBeInstanceOf(McpError); 1661 | 1662 | // @ts-expect-error - we know that error is an McpError 1663 | expect(error.code).toBe(ErrorCode.InvalidParams); 1664 | 1665 | // @ts-expect-error - we know that error is an McpError 1666 | expect(error.message).toBe( 1667 | "MCP error -32602: MCP error -32602: Tool 'add' parameter validation failed: b: Expected number, received string", 1668 | ); 1669 | } 1670 | 1671 | expect( 1672 | await client.callTool({ 1673 | arguments: { 1674 | a: 1, 1675 | b: 2, 1676 | }, 1677 | name: "add", 1678 | }), 1679 | ).toEqual({ 1680 | content: [{ text: "3", type: "text" }], 1681 | }); 1682 | }, 1683 | server: async () => { 1684 | const server = new FastMCP({ 1685 | name: "Test", 1686 | version: "1.0.0", 1687 | }); 1688 | 1689 | server.addTool({ 1690 | description: "Add two numbers", 1691 | execute: async (args) => { 1692 | return String(args.a + args.b); 1693 | }, 1694 | name: "add", 1695 | parameters: z.object({ 1696 | a: z.number(), 1697 | b: z.number(), 1698 | }), 1699 | }); 1700 | 1701 | return server; 1702 | }, 1703 | }); 1704 | }); 1705 | 1706 | test("allows new clients to connect after a client disconnects", async () => { 1707 | const port = await getRandomPort(); 1708 | 1709 | const server = new FastMCP({ 1710 | name: "Test", 1711 | version: "1.0.0", 1712 | }); 1713 | 1714 | server.addTool({ 1715 | description: "Add two numbers", 1716 | execute: async (args) => { 1717 | return String(args.a + args.b); 1718 | }, 1719 | name: "add", 1720 | parameters: z.object({ 1721 | a: z.number(), 1722 | b: z.number(), 1723 | }), 1724 | }); 1725 | 1726 | await server.start({ 1727 | httpStream: { 1728 | port, 1729 | }, 1730 | transportType: "httpStream", 1731 | }); 1732 | 1733 | const client1 = new Client( 1734 | { 1735 | name: "example-client", 1736 | version: "1.0.0", 1737 | }, 1738 | { 1739 | capabilities: {}, 1740 | }, 1741 | ); 1742 | 1743 | const transport1 = new SSEClientTransport( 1744 | new URL(`http://localhost:${port}/sse`), 1745 | ); 1746 | 1747 | await client1.connect(transport1); 1748 | 1749 | expect( 1750 | await client1.callTool({ 1751 | arguments: { 1752 | a: 1, 1753 | b: 2, 1754 | }, 1755 | name: "add", 1756 | }), 1757 | ).toEqual({ 1758 | content: [{ text: "3", type: "text" }], 1759 | }); 1760 | 1761 | await client1.close(); 1762 | 1763 | const client2 = new Client( 1764 | { 1765 | name: "example-client", 1766 | version: "1.0.0", 1767 | }, 1768 | { 1769 | capabilities: {}, 1770 | }, 1771 | ); 1772 | 1773 | const transport2 = new SSEClientTransport( 1774 | new URL(`http://localhost:${port}/sse`), 1775 | ); 1776 | 1777 | await client2.connect(transport2); 1778 | 1779 | expect( 1780 | await client2.callTool({ 1781 | arguments: { 1782 | a: 1, 1783 | b: 2, 1784 | }, 1785 | name: "add", 1786 | }), 1787 | ).toEqual({ 1788 | content: [{ text: "3", type: "text" }], 1789 | }); 1790 | 1791 | await client2.close(); 1792 | 1793 | await server.stop(); 1794 | }); 1795 | 1796 | test("able to close server immediately after starting it", async () => { 1797 | const port = await getRandomPort(); 1798 | 1799 | const server = new FastMCP({ 1800 | name: "Test", 1801 | version: "1.0.0", 1802 | }); 1803 | 1804 | await server.start({ 1805 | httpStream: { 1806 | port, 1807 | }, 1808 | transportType: "httpStream", 1809 | }); 1810 | 1811 | // We were previously not waiting for the server to start. 1812 | // Therefore, this would have caused error 'Server is not running.'. 1813 | await server.stop(); 1814 | }); 1815 | 1816 | test("closing event source does not produce error", async () => { 1817 | const port = await getRandomPort(); 1818 | 1819 | const server = new FastMCP({ 1820 | name: "Test", 1821 | version: "1.0.0", 1822 | }); 1823 | 1824 | server.addTool({ 1825 | description: "Add two numbers", 1826 | execute: async (args) => { 1827 | return String(args.a + args.b); 1828 | }, 1829 | name: "add", 1830 | parameters: z.object({ 1831 | a: z.number(), 1832 | b: z.number(), 1833 | }), 1834 | }); 1835 | 1836 | await server.start({ 1837 | httpStream: { 1838 | port, 1839 | }, 1840 | transportType: "httpStream", 1841 | }); 1842 | 1843 | const eventSource = await new Promise((onMessage) => { 1844 | const eventSource = createEventSource({ 1845 | onConnect: () => { 1846 | console.info("connected"); 1847 | }, 1848 | onDisconnect: () => { 1849 | console.info("disconnected"); 1850 | }, 1851 | onMessage: () => { 1852 | onMessage(eventSource); 1853 | }, 1854 | url: `http://127.0.0.1:${port}/sse`, 1855 | }); 1856 | }); 1857 | 1858 | expect(eventSource.readyState).toBe("open"); 1859 | 1860 | eventSource.close(); 1861 | 1862 | // We were getting unhandled error 'Not connected' 1863 | // https://github.com/punkpeye/mcp-proxy/commit/62cf27d5e3dfcbc353e8d03c7714a62c37177b52 1864 | await delay(1000); 1865 | 1866 | await server.stop(); 1867 | }); 1868 | 1869 | test("provides auth to tools", async () => { 1870 | const port = await getRandomPort(); 1871 | 1872 | const authenticate = vi.fn(async () => { 1873 | return { 1874 | id: 1, 1875 | }; 1876 | }); 1877 | 1878 | const server = new FastMCP<{ id: number }>({ 1879 | authenticate, 1880 | name: "Test", 1881 | version: "1.0.0", 1882 | }); 1883 | 1884 | const execute = vi.fn(async (args) => { 1885 | return String(args.a + args.b); 1886 | }); 1887 | 1888 | server.addTool({ 1889 | description: "Add two numbers", 1890 | execute, 1891 | name: "add", 1892 | parameters: z.object({ 1893 | a: z.number(), 1894 | b: z.number(), 1895 | }), 1896 | }); 1897 | 1898 | await server.start({ 1899 | httpStream: { 1900 | port, 1901 | }, 1902 | transportType: "httpStream", 1903 | }); 1904 | 1905 | const client = new Client( 1906 | { 1907 | name: "example-client", 1908 | version: "1.0.0", 1909 | }, 1910 | { 1911 | capabilities: {}, 1912 | }, 1913 | ); 1914 | 1915 | const transport = new SSEClientTransport( 1916 | new URL(`http://localhost:${port}/sse`), 1917 | { 1918 | eventSourceInit: { 1919 | fetch: async (url, init) => { 1920 | return fetch(url, { 1921 | ...init, 1922 | headers: { 1923 | ...init?.headers, 1924 | "x-api-key": "123", 1925 | }, 1926 | }); 1927 | }, 1928 | }, 1929 | }, 1930 | ); 1931 | 1932 | await client.connect(transport); 1933 | 1934 | expect( 1935 | authenticate, 1936 | "authenticate should have been called", 1937 | ).toHaveBeenCalledTimes(1); 1938 | 1939 | expect( 1940 | await client.callTool({ 1941 | arguments: { 1942 | a: 1, 1943 | b: 2, 1944 | }, 1945 | name: "add", 1946 | }), 1947 | ).toEqual({ 1948 | content: [{ text: "3", type: "text" }], 1949 | }); 1950 | 1951 | expect(execute, "execute should have been called").toHaveBeenCalledTimes(1); 1952 | 1953 | expect(execute).toHaveBeenCalledWith( 1954 | { 1955 | a: 1, 1956 | b: 2, 1957 | }, 1958 | { 1959 | log: { 1960 | debug: expect.any(Function), 1961 | error: expect.any(Function), 1962 | info: expect.any(Function), 1963 | warn: expect.any(Function), 1964 | }, 1965 | reportProgress: expect.any(Function), 1966 | session: { id: 1 }, 1967 | streamContent: expect.any(Function), 1968 | }, 1969 | ); 1970 | }); 1971 | 1972 | test("supports streaming output from tools", async () => { 1973 | let streamResult: { content: Array<{ text: string; type: string }> }; 1974 | 1975 | await runWithTestServer({ 1976 | run: async ({ client }) => { 1977 | const result = await client.callTool({ 1978 | arguments: {}, 1979 | name: "streaming-void-tool", 1980 | }); 1981 | 1982 | expect(result).toEqual({ 1983 | content: [], 1984 | }); 1985 | 1986 | streamResult = (await client.callTool({ 1987 | arguments: {}, 1988 | name: "streaming-with-result", 1989 | })) as { content: Array<{ text: string; type: string }> }; 1990 | 1991 | expect(streamResult).toEqual({ 1992 | content: [{ text: "Final result after streaming", type: "text" }], 1993 | }); 1994 | }, 1995 | server: async () => { 1996 | const server = new FastMCP({ 1997 | name: "Test", 1998 | version: "1.0.0", 1999 | }); 2000 | 2001 | server.addTool({ 2002 | annotations: { 2003 | streamingHint: true, 2004 | }, 2005 | description: "A streaming tool that returns void", 2006 | execute: async (_args, context) => { 2007 | await context.streamContent({ 2008 | text: "Streaming content 1", 2009 | type: "text", 2010 | }); 2011 | 2012 | await context.streamContent({ 2013 | text: "Streaming content 2", 2014 | type: "text", 2015 | }); 2016 | 2017 | // Return void 2018 | return; 2019 | }, 2020 | name: "streaming-void-tool", 2021 | parameters: z.object({}), 2022 | }); 2023 | 2024 | server.addTool({ 2025 | annotations: { 2026 | streamingHint: true, 2027 | }, 2028 | description: "A streaming tool that returns a result.", 2029 | execute: async (_args, context) => { 2030 | await context.streamContent({ 2031 | text: "Streaming content 1", 2032 | type: "text", 2033 | }); 2034 | 2035 | await context.streamContent({ 2036 | text: "Streaming content 2", 2037 | type: "text", 2038 | }); 2039 | 2040 | return "Final result after streaming"; 2041 | }, 2042 | name: "streaming-with-result", 2043 | parameters: z.object({}), 2044 | }); 2045 | 2046 | return server; 2047 | }, 2048 | }); 2049 | }); 2050 | 2051 | test("blocks unauthorized requests", async () => { 2052 | const port = await getRandomPort(); 2053 | 2054 | const server = new FastMCP<{ id: number }>({ 2055 | authenticate: async () => { 2056 | throw new Response(null, { 2057 | status: 401, 2058 | statusText: "Unauthorized", 2059 | }); 2060 | }, 2061 | name: "Test", 2062 | version: "1.0.0", 2063 | }); 2064 | 2065 | await server.start({ 2066 | httpStream: { 2067 | port, 2068 | }, 2069 | transportType: "httpStream", 2070 | }); 2071 | 2072 | const client = new Client( 2073 | { 2074 | name: "example-client", 2075 | version: "1.0.0", 2076 | }, 2077 | { 2078 | capabilities: {}, 2079 | }, 2080 | ); 2081 | 2082 | const transport = new SSEClientTransport( 2083 | new URL(`http://localhost:${port}/sse`), 2084 | ); 2085 | 2086 | expect(async () => { 2087 | await client.connect(transport); 2088 | }).rejects.toThrow("SSE error: Non-200 status code (401)"); 2089 | }); 2090 | 2091 | // We now use a direct approach for testing HTTP Stream functionality 2092 | // rather than a helper function 2093 | 2094 | // Set longer timeout for HTTP Stream tests 2095 | test("HTTP Stream: calls a tool", { timeout: 20000 }, async () => { 2096 | console.log("Starting HTTP Stream test..."); 2097 | 2098 | const port = await getRandomPort(); 2099 | 2100 | // Create server directly (don't use helper function) 2101 | const server = new FastMCP({ 2102 | name: "Test", 2103 | version: "1.0.0", 2104 | }); 2105 | 2106 | server.addTool({ 2107 | description: "Add two numbers", 2108 | execute: async (args) => { 2109 | return String(args.a + args.b); 2110 | }, 2111 | name: "add", 2112 | parameters: z.object({ 2113 | a: z.number(), 2114 | b: z.number(), 2115 | }), 2116 | }); 2117 | 2118 | await server.start({ 2119 | httpStream: { 2120 | port, 2121 | }, 2122 | transportType: "httpStream", 2123 | }); 2124 | 2125 | try { 2126 | // Create client 2127 | const client = new Client( 2128 | { 2129 | name: "example-client", 2130 | version: "1.0.0", 2131 | }, 2132 | { 2133 | capabilities: {}, 2134 | }, 2135 | ); 2136 | 2137 | // IMPORTANT: Don't provide sessionId manually with HTTP streaming 2138 | // The server will generate a session ID automatically 2139 | const transport = new StreamableHTTPClientTransport( 2140 | new URL(`http://localhost:${port}/stream`), 2141 | ); 2142 | 2143 | // Connect client to server and wait for session to be ready 2144 | const sessionPromise = new Promise((resolve) => { 2145 | server.on("connect", async (event) => { 2146 | await event.session.waitForReady(); 2147 | resolve(event.session); 2148 | }); 2149 | }); 2150 | 2151 | await client.connect(transport); 2152 | await sessionPromise; 2153 | 2154 | // Call tool 2155 | const result = await client.callTool({ 2156 | arguments: { 2157 | a: 1, 2158 | b: 2, 2159 | }, 2160 | name: "add", 2161 | }); 2162 | 2163 | // Check result 2164 | expect(result).toEqual({ 2165 | content: [{ text: "3", type: "text" }], 2166 | }); 2167 | 2168 | // Clean up connection 2169 | await transport.terminateSession(); 2170 | 2171 | await client.close(); 2172 | } finally { 2173 | await server.stop(); 2174 | } 2175 | }); 2176 | -------------------------------------------------------------------------------- /src/FastMCP.ts: -------------------------------------------------------------------------------- 1 | import { Server } from "@modelcontextprotocol/sdk/server/index.js"; 2 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 3 | import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; 4 | import { 5 | CallToolRequestSchema, 6 | ClientCapabilities, 7 | CompleteRequestSchema, 8 | CreateMessageRequestSchema, 9 | ErrorCode, 10 | GetPromptRequestSchema, 11 | ListPromptsRequestSchema, 12 | ListResourcesRequestSchema, 13 | ListResourceTemplatesRequestSchema, 14 | ListToolsRequestSchema, 15 | McpError, 16 | ReadResourceRequestSchema, 17 | Root, 18 | RootsListChangedNotificationSchema, 19 | ServerCapabilities, 20 | SetLevelRequestSchema, 21 | } from "@modelcontextprotocol/sdk/types.js"; 22 | import { StandardSchemaV1 } from "@standard-schema/spec"; 23 | import { EventEmitter } from "events"; 24 | import { fileTypeFromBuffer } from "file-type"; 25 | import { readFile } from "fs/promises"; 26 | import Fuse from "fuse.js"; 27 | import http from "http"; 28 | import { startHTTPServer } from "mcp-proxy"; 29 | import { StrictEventEmitter } from "strict-event-emitter-types"; 30 | import { setTimeout as delay } from "timers/promises"; 31 | import { fetch } from "undici"; 32 | import parseURITemplate from "uri-templates"; 33 | import { toJsonSchema } from "xsschema"; 34 | import { z } from "zod"; 35 | 36 | export type SSEServer = { 37 | close: () => Promise; 38 | }; 39 | 40 | type FastMCPEvents = { 41 | connect: (event: { session: FastMCPSession }) => void; 42 | disconnect: (event: { session: FastMCPSession }) => void; 43 | }; 44 | 45 | type FastMCPSessionEvents = { 46 | error: (event: { error: Error }) => void; 47 | ready: () => void; 48 | rootsChanged: (event: { roots: Root[] }) => void; 49 | }; 50 | 51 | export const imageContent = async ( 52 | input: { buffer: Buffer } | { path: string } | { url: string }, 53 | ): Promise => { 54 | let rawData: Buffer; 55 | 56 | try { 57 | if ("url" in input) { 58 | try { 59 | const response = await fetch(input.url); 60 | 61 | if (!response.ok) { 62 | throw new Error( 63 | `Server responded with status: ${response.status} - ${response.statusText}`, 64 | ); 65 | } 66 | 67 | rawData = Buffer.from(await response.arrayBuffer()); 68 | } catch (error) { 69 | throw new Error( 70 | `Failed to fetch image from URL (${input.url}): ${error instanceof Error ? error.message : String(error)}`, 71 | ); 72 | } 73 | } else if ("path" in input) { 74 | try { 75 | rawData = await readFile(input.path); 76 | } catch (error) { 77 | throw new Error( 78 | `Failed to read image from path (${input.path}): ${error instanceof Error ? error.message : String(error)}`, 79 | ); 80 | } 81 | } else if ("buffer" in input) { 82 | rawData = input.buffer; 83 | } else { 84 | throw new Error( 85 | "Invalid input: Provide a valid 'url', 'path', or 'buffer'", 86 | ); 87 | } 88 | 89 | const mimeType = await fileTypeFromBuffer(rawData); 90 | 91 | if (!mimeType || !mimeType.mime.startsWith("image/")) { 92 | console.warn( 93 | `Warning: Content may not be a valid image. Detected MIME: ${mimeType?.mime || "unknown"}`, 94 | ); 95 | } 96 | 97 | const base64Data = rawData.toString("base64"); 98 | 99 | return { 100 | data: base64Data, 101 | mimeType: mimeType?.mime ?? "image/png", 102 | type: "image", 103 | } as const; 104 | } catch (error) { 105 | if (error instanceof Error) { 106 | throw error; 107 | } else { 108 | throw new Error(`Unexpected error processing image: ${String(error)}`); 109 | } 110 | } 111 | }; 112 | 113 | export const audioContent = async ( 114 | input: { buffer: Buffer } | { path: string } | { url: string }, 115 | ): Promise => { 116 | let rawData: Buffer; 117 | 118 | try { 119 | if ("url" in input) { 120 | try { 121 | const response = await fetch(input.url); 122 | 123 | if (!response.ok) { 124 | throw new Error( 125 | `Server responded with status: ${response.status} - ${response.statusText}`, 126 | ); 127 | } 128 | 129 | rawData = Buffer.from(await response.arrayBuffer()); 130 | } catch (error) { 131 | throw new Error( 132 | `Failed to fetch audio from URL (${input.url}): ${error instanceof Error ? error.message : String(error)}`, 133 | ); 134 | } 135 | } else if ("path" in input) { 136 | try { 137 | rawData = await readFile(input.path); 138 | } catch (error) { 139 | throw new Error( 140 | `Failed to read audio from path (${input.path}): ${error instanceof Error ? error.message : String(error)}`, 141 | ); 142 | } 143 | } else if ("buffer" in input) { 144 | rawData = input.buffer; 145 | } else { 146 | throw new Error( 147 | "Invalid input: Provide a valid 'url', 'path', or 'buffer'", 148 | ); 149 | } 150 | 151 | const mimeType = await fileTypeFromBuffer(rawData); 152 | 153 | if (!mimeType || !mimeType.mime.startsWith("audio/")) { 154 | console.warn( 155 | `Warning: Content may not be a valid audio file. Detected MIME: ${mimeType?.mime || "unknown"}`, 156 | ); 157 | } 158 | 159 | const base64Data = rawData.toString("base64"); 160 | 161 | return { 162 | data: base64Data, 163 | mimeType: mimeType?.mime ?? "audio/mpeg", 164 | type: "audio", 165 | } as const; 166 | } catch (error) { 167 | if (error instanceof Error) { 168 | throw error; 169 | } else { 170 | throw new Error(`Unexpected error processing audio: ${String(error)}`); 171 | } 172 | } 173 | }; 174 | 175 | type Context = { 176 | log: { 177 | debug: (message: string, data?: SerializableValue) => void; 178 | error: (message: string, data?: SerializableValue) => void; 179 | info: (message: string, data?: SerializableValue) => void; 180 | warn: (message: string, data?: SerializableValue) => void; 181 | }; 182 | reportProgress: (progress: Progress) => Promise; 183 | session: T | undefined; 184 | streamContent: (content: Content | Content[]) => Promise; 185 | }; 186 | 187 | type Extra = unknown; 188 | 189 | type Extras = Record; 190 | 191 | type Literal = boolean | null | number | string | undefined; 192 | 193 | type Progress = { 194 | /** 195 | * The progress thus far. This should increase every time progress is made, even if the total is unknown. 196 | */ 197 | progress: number; 198 | /** 199 | * Total number of items to process (or total progress required), if known. 200 | */ 201 | total?: number; 202 | }; 203 | 204 | type SerializableValue = 205 | | { [key: string]: SerializableValue } 206 | | Literal 207 | | SerializableValue[]; 208 | 209 | type TextContent = { 210 | text: string; 211 | type: "text"; 212 | }; 213 | 214 | type ToolParameters = StandardSchemaV1; 215 | 216 | abstract class FastMCPError extends Error { 217 | public constructor(message?: string) { 218 | super(message); 219 | this.name = new.target.name; 220 | } 221 | } 222 | 223 | export class UnexpectedStateError extends FastMCPError { 224 | public extras?: Extras; 225 | 226 | public constructor(message: string, extras?: Extras) { 227 | super(message); 228 | this.name = new.target.name; 229 | this.extras = extras; 230 | } 231 | } 232 | 233 | /** 234 | * An error that is meant to be surfaced to the user. 235 | */ 236 | export class UserError extends UnexpectedStateError {} 237 | 238 | const TextContentZodSchema = z 239 | .object({ 240 | /** 241 | * The text content of the message. 242 | */ 243 | text: z.string(), 244 | type: z.literal("text"), 245 | }) 246 | .strict() satisfies z.ZodType; 247 | 248 | type ImageContent = { 249 | data: string; 250 | mimeType: string; 251 | type: "image"; 252 | }; 253 | 254 | const ImageContentZodSchema = z 255 | .object({ 256 | /** 257 | * The base64-encoded image data. 258 | */ 259 | data: z.string().base64(), 260 | /** 261 | * The MIME type of the image. Different providers may support different image types. 262 | */ 263 | mimeType: z.string(), 264 | type: z.literal("image"), 265 | }) 266 | .strict() satisfies z.ZodType; 267 | 268 | type AudioContent = { 269 | data: string; 270 | mimeType: string; 271 | type: "audio"; 272 | }; 273 | 274 | const AudioContentZodSchema = z 275 | .object({ 276 | /** 277 | * The base64-encoded audio data. 278 | */ 279 | data: z.string().base64(), 280 | mimeType: z.string(), 281 | type: z.literal("audio"), 282 | }) 283 | .strict() satisfies z.ZodType; 284 | 285 | type ResourceContent = { 286 | resource: { 287 | blob?: string; 288 | mimeType?: string; 289 | text?: string; 290 | uri: string; 291 | }; 292 | type: "resource"; 293 | }; 294 | 295 | const ResourceContentZodSchema = z 296 | .object({ 297 | resource: z.object({ 298 | blob: z.string().optional(), 299 | mimeType: z.string().optional(), 300 | text: z.string().optional(), 301 | uri: z.string(), 302 | }), 303 | type: z.literal("resource"), 304 | }) 305 | .strict() satisfies z.ZodType; 306 | 307 | type Content = AudioContent | ImageContent | ResourceContent | TextContent; 308 | 309 | const ContentZodSchema = z.discriminatedUnion("type", [ 310 | TextContentZodSchema, 311 | ImageContentZodSchema, 312 | AudioContentZodSchema, 313 | ResourceContentZodSchema, 314 | ]) satisfies z.ZodType; 315 | 316 | type ContentResult = { 317 | content: Content[]; 318 | isError?: boolean; 319 | }; 320 | 321 | const ContentResultZodSchema = z 322 | .object({ 323 | content: ContentZodSchema.array(), 324 | isError: z.boolean().optional(), 325 | }) 326 | .strict() satisfies z.ZodType; 327 | 328 | type Completion = { 329 | hasMore?: boolean; 330 | total?: number; 331 | values: string[]; 332 | }; 333 | 334 | /** 335 | * https://github.com/modelcontextprotocol/typescript-sdk/blob/3164da64d085ec4e022ae881329eee7b72f208d4/src/types.ts#L983-L1003 336 | */ 337 | const CompletionZodSchema = z.object({ 338 | /** 339 | * Indicates whether there are additional completion options beyond those provided in the current response, even if the exact total is unknown. 340 | */ 341 | hasMore: z.optional(z.boolean()), 342 | /** 343 | * The total number of completion options available. This can exceed the number of values actually sent in the response. 344 | */ 345 | total: z.optional(z.number().int()), 346 | /** 347 | * An array of completion values. Must not exceed 100 items. 348 | */ 349 | values: z.array(z.string()).max(100), 350 | }) satisfies z.ZodType; 351 | 352 | type ArgumentValueCompleter = (value: string) => Promise; 353 | 354 | type InputPrompt< 355 | Arguments extends InputPromptArgument[] = InputPromptArgument[], 356 | Args = PromptArgumentsToObject, 357 | > = { 358 | arguments?: InputPromptArgument[]; 359 | description?: string; 360 | load: (args: Args) => Promise; 361 | name: string; 362 | }; 363 | 364 | type InputPromptArgument = Readonly<{ 365 | complete?: ArgumentValueCompleter; 366 | description?: string; 367 | enum?: string[]; 368 | name: string; 369 | required?: boolean; 370 | }>; 371 | 372 | type InputResourceTemplate< 373 | Arguments extends ResourceTemplateArgument[] = ResourceTemplateArgument[], 374 | > = { 375 | arguments: Arguments; 376 | description?: string; 377 | load: ( 378 | args: ResourceTemplateArgumentsToObject, 379 | ) => Promise; 380 | mimeType?: string; 381 | name: string; 382 | uriTemplate: string; 383 | }; 384 | 385 | type InputResourceTemplateArgument = Readonly<{ 386 | complete?: ArgumentValueCompleter; 387 | description?: string; 388 | name: string; 389 | required?: boolean; 390 | }>; 391 | 392 | type LoggingLevel = 393 | | "alert" 394 | | "critical" 395 | | "debug" 396 | | "emergency" 397 | | "error" 398 | | "info" 399 | | "notice" 400 | | "warning"; 401 | 402 | type Prompt< 403 | Arguments extends PromptArgument[] = PromptArgument[], 404 | Args = PromptArgumentsToObject, 405 | > = { 406 | arguments?: PromptArgument[]; 407 | complete?: (name: string, value: string) => Promise; 408 | description?: string; 409 | load: (args: Args) => Promise; 410 | name: string; 411 | }; 412 | 413 | type PromptArgument = Readonly<{ 414 | complete?: ArgumentValueCompleter; 415 | description?: string; 416 | enum?: string[]; 417 | name: string; 418 | required?: boolean; 419 | }>; 420 | 421 | type PromptArgumentsToObject = 422 | { 423 | [K in T[number]["name"]]: Extract< 424 | T[number], 425 | { name: K } 426 | >["required"] extends true 427 | ? string 428 | : string | undefined; 429 | }; 430 | 431 | type Resource = { 432 | complete?: (name: string, value: string) => Promise; 433 | description?: string; 434 | load: () => Promise; 435 | mimeType?: string; 436 | name: string; 437 | uri: string; 438 | }; 439 | 440 | type ResourceResult = 441 | | { 442 | blob: string; 443 | } 444 | | { 445 | text: string; 446 | }; 447 | 448 | type ResourceTemplate< 449 | Arguments extends ResourceTemplateArgument[] = ResourceTemplateArgument[], 450 | > = { 451 | arguments: Arguments; 452 | complete?: (name: string, value: string) => Promise; 453 | description?: string; 454 | load: ( 455 | args: ResourceTemplateArgumentsToObject, 456 | ) => Promise; 457 | mimeType?: string; 458 | name: string; 459 | uriTemplate: string; 460 | }; 461 | 462 | type ResourceTemplateArgument = Readonly<{ 463 | complete?: ArgumentValueCompleter; 464 | description?: string; 465 | name: string; 466 | required?: boolean; 467 | }>; 468 | 469 | type ResourceTemplateArgumentsToObject = { 470 | [K in T[number]["name"]]: string; 471 | }; 472 | 473 | type ServerOptions = { 474 | authenticate?: Authenticate; 475 | /** 476 | * Configuration for the health-check endpoint that can be exposed when the 477 | * server is running using the HTTP Stream transport. When enabled, the 478 | * server will respond to an HTTP GET request with the configured path (by 479 | * default "/health") rendering a plain-text response (by default "ok") and 480 | * the configured status code (by default 200). 481 | * 482 | * The endpoint is only added when the server is started with 483 | * `transportType: "httpStream"` – it is ignored for the stdio transport. 484 | */ 485 | health?: { 486 | /** 487 | * When set to `false` the health-check endpoint is disabled. 488 | * @default true 489 | */ 490 | enabled?: boolean; 491 | 492 | /** 493 | * Plain-text body returned by the endpoint. 494 | * @default "ok" 495 | */ 496 | message?: string; 497 | 498 | /** 499 | * HTTP path that should be handled. 500 | * @default "/health" 501 | */ 502 | path?: string; 503 | 504 | /** 505 | * HTTP response status that will be returned. 506 | * @default 200 507 | */ 508 | status?: number; 509 | }; 510 | instructions?: string; 511 | name: string; 512 | 513 | ping?: { 514 | /** 515 | * Whether ping should be enabled by default. 516 | * - true for SSE or HTTP Stream 517 | * - false for stdio 518 | */ 519 | enabled?: boolean; 520 | /** 521 | * Interval 522 | * @default 5000 (5s) 523 | */ 524 | intervalMs?: number; 525 | /** 526 | * Logging level for ping-related messages. 527 | * @default 'debug' 528 | */ 529 | logLevel?: LoggingLevel; 530 | }; 531 | /** 532 | * Configuration for roots capability 533 | */ 534 | roots?: { 535 | /** 536 | * Whether roots capability should be enabled 537 | * Set to false to completely disable roots support 538 | * @default true 539 | */ 540 | enabled?: boolean; 541 | }; 542 | version: `${number}.${number}.${number}`; 543 | }; 544 | 545 | type Tool< 546 | T extends FastMCPSessionAuth, 547 | Params extends ToolParameters = ToolParameters, 548 | > = { 549 | annotations?: { 550 | /** 551 | * When true, the tool leverages incremental content streaming 552 | * Return void for tools that handle all their output via streaming 553 | */ 554 | streamingHint?: boolean; 555 | } & ToolAnnotations; 556 | description?: string; 557 | execute: ( 558 | args: StandardSchemaV1.InferOutput, 559 | context: Context, 560 | ) => Promise< 561 | | AudioContent 562 | | ContentResult 563 | | ImageContent 564 | | ResourceContent 565 | | string 566 | | TextContent 567 | | void 568 | >; 569 | name: string; 570 | parameters?: Params; 571 | timeoutMs?: number; 572 | }; 573 | 574 | /** 575 | * Tool annotations as defined in MCP Specification (2025-03-26) 576 | * These provide hints about a tool's behavior. 577 | */ 578 | type ToolAnnotations = { 579 | /** 580 | * If true, the tool may perform destructive updates 581 | * Only meaningful when readOnlyHint is false 582 | * @default true 583 | */ 584 | destructiveHint?: boolean; 585 | 586 | /** 587 | * If true, calling the tool repeatedly with the same arguments has no additional effect 588 | * Only meaningful when readOnlyHint is false 589 | * @default false 590 | */ 591 | idempotentHint?: boolean; 592 | 593 | /** 594 | * If true, the tool may interact with an "open world" of external entities 595 | * @default true 596 | */ 597 | openWorldHint?: boolean; 598 | 599 | /** 600 | * If true, indicates the tool does not modify its environment 601 | * @default false 602 | */ 603 | readOnlyHint?: boolean; 604 | 605 | /** 606 | * A human-readable title for the tool, useful for UI display 607 | */ 608 | title?: string; 609 | }; 610 | 611 | const FastMCPSessionEventEmitterBase: { 612 | new (): StrictEventEmitter; 613 | } = EventEmitter; 614 | 615 | type FastMCPSessionAuth = Record | undefined; 616 | 617 | type SamplingResponse = { 618 | content: AudioContent | ImageContent | TextContent; 619 | model: string; 620 | role: "assistant" | "user"; 621 | stopReason?: "endTurn" | "maxTokens" | "stopSequence" | string; 622 | }; 623 | 624 | class FastMCPSessionEventEmitter extends FastMCPSessionEventEmitterBase {} 625 | 626 | export class FastMCPSession< 627 | T extends FastMCPSessionAuth = FastMCPSessionAuth, 628 | > extends FastMCPSessionEventEmitter { 629 | public get clientCapabilities(): ClientCapabilities | null { 630 | return this.#clientCapabilities ?? null; 631 | } 632 | public get isReady(): boolean { 633 | return this.#connectionState === "ready"; 634 | } 635 | public get loggingLevel(): LoggingLevel { 636 | return this.#loggingLevel; 637 | } 638 | public get roots(): Root[] { 639 | return this.#roots; 640 | } 641 | public get server(): Server { 642 | return this.#server; 643 | } 644 | #auth: T | undefined; 645 | #capabilities: ServerCapabilities = {}; 646 | #clientCapabilities?: ClientCapabilities; 647 | #connectionState: "closed" | "connecting" | "error" | "ready" = "connecting"; 648 | #loggingLevel: LoggingLevel = "info"; 649 | #pingConfig?: ServerOptions["ping"]; 650 | #pingInterval: null | ReturnType = null; 651 | 652 | #prompts: Prompt[] = []; 653 | 654 | #resources: Resource[] = []; 655 | 656 | #resourceTemplates: ResourceTemplate[] = []; 657 | 658 | #roots: Root[] = []; 659 | 660 | #rootsConfig?: ServerOptions["roots"]; 661 | 662 | #server: Server; 663 | 664 | constructor({ 665 | auth, 666 | instructions, 667 | name, 668 | ping, 669 | prompts, 670 | resources, 671 | resourcesTemplates, 672 | roots, 673 | tools, 674 | version, 675 | }: { 676 | auth?: T; 677 | instructions?: string; 678 | name: string; 679 | ping?: ServerOptions["ping"]; 680 | prompts: Prompt[]; 681 | resources: Resource[]; 682 | resourcesTemplates: InputResourceTemplate[]; 683 | roots?: ServerOptions["roots"]; 684 | tools: Tool[]; 685 | version: string; 686 | }) { 687 | super(); 688 | 689 | this.#auth = auth; 690 | this.#pingConfig = ping; 691 | this.#rootsConfig = roots; 692 | 693 | if (tools.length) { 694 | this.#capabilities.tools = {}; 695 | } 696 | 697 | if (resources.length || resourcesTemplates.length) { 698 | this.#capabilities.resources = {}; 699 | } 700 | 701 | if (prompts.length) { 702 | for (const prompt of prompts) { 703 | this.addPrompt(prompt); 704 | } 705 | 706 | this.#capabilities.prompts = {}; 707 | } 708 | 709 | this.#capabilities.logging = {}; 710 | 711 | this.#server = new Server( 712 | { name: name, version: version }, 713 | { capabilities: this.#capabilities, instructions: instructions }, 714 | ); 715 | 716 | this.setupErrorHandling(); 717 | this.setupLoggingHandlers(); 718 | this.setupRootsHandlers(); 719 | this.setupCompleteHandlers(); 720 | 721 | if (tools.length) { 722 | this.setupToolHandlers(tools); 723 | } 724 | 725 | if (resources.length || resourcesTemplates.length) { 726 | for (const resource of resources) { 727 | this.addResource(resource); 728 | } 729 | 730 | this.setupResourceHandlers(resources); 731 | 732 | if (resourcesTemplates.length) { 733 | for (const resourceTemplate of resourcesTemplates) { 734 | this.addResourceTemplate(resourceTemplate); 735 | } 736 | 737 | this.setupResourceTemplateHandlers(resourcesTemplates); 738 | } 739 | } 740 | 741 | if (prompts.length) { 742 | this.setupPromptHandlers(prompts); 743 | } 744 | } 745 | 746 | public async close() { 747 | this.#connectionState = "closed"; 748 | 749 | if (this.#pingInterval) { 750 | clearInterval(this.#pingInterval); 751 | } 752 | 753 | try { 754 | await this.#server.close(); 755 | } catch (error) { 756 | console.error("[FastMCP error]", "could not close server", error); 757 | } 758 | } 759 | 760 | public async connect(transport: Transport) { 761 | if (this.#server.transport) { 762 | throw new UnexpectedStateError("Server is already connected"); 763 | } 764 | 765 | this.#connectionState = "connecting"; 766 | 767 | try { 768 | await this.#server.connect(transport); 769 | 770 | let attempt = 0; 771 | 772 | while (attempt++ < 10) { 773 | const capabilities = this.#server.getClientCapabilities(); 774 | 775 | if (capabilities) { 776 | this.#clientCapabilities = capabilities; 777 | 778 | break; 779 | } 780 | 781 | await delay(100); 782 | } 783 | 784 | if (!this.#clientCapabilities) { 785 | console.warn("[FastMCP warning] could not infer client capabilities"); 786 | } 787 | 788 | if ( 789 | this.#clientCapabilities?.roots?.listChanged && 790 | typeof this.#server.listRoots === "function" 791 | ) { 792 | try { 793 | const roots = await this.#server.listRoots(); 794 | this.#roots = roots.roots; 795 | } catch (e) { 796 | if (e instanceof McpError && e.code === ErrorCode.MethodNotFound) { 797 | console.debug( 798 | "[FastMCP debug] listRoots method not supported by client", 799 | ); 800 | } else { 801 | console.error( 802 | `[FastMCP error] received error listing roots.\n\n${e instanceof Error ? e.stack : JSON.stringify(e)}`, 803 | ); 804 | } 805 | } 806 | } 807 | 808 | if (this.#clientCapabilities) { 809 | const pingConfig = this.#getPingConfig(transport); 810 | 811 | if (pingConfig.enabled) { 812 | this.#pingInterval = setInterval(async () => { 813 | try { 814 | await this.#server.ping(); 815 | } catch { 816 | // The reason we are not emitting an error here is because some clients 817 | // seem to not respond to the ping request, and we don't want to crash the server, 818 | // e.g., https://github.com/punkpeye/fastmcp/issues/38. 819 | const logLevel = pingConfig.logLevel; 820 | 821 | if (logLevel === "debug") { 822 | console.debug("[FastMCP debug] server ping failed"); 823 | } else if (logLevel === "warning") { 824 | console.warn( 825 | "[FastMCP warning] server is not responding to ping", 826 | ); 827 | } else if (logLevel === "error") { 828 | console.error( 829 | "[FastMCP error] server is not responding to ping", 830 | ); 831 | } else { 832 | console.info("[FastMCP info] server ping failed"); 833 | } 834 | } 835 | }, pingConfig.intervalMs); 836 | } 837 | } 838 | 839 | // Mark connection as ready and emit event 840 | this.#connectionState = "ready"; 841 | this.emit("ready"); 842 | } catch (error) { 843 | this.#connectionState = "error"; 844 | const errorEvent = { 845 | error: error instanceof Error ? error : new Error(String(error)), 846 | }; 847 | this.emit("error", errorEvent); 848 | throw error; 849 | } 850 | } 851 | 852 | public async requestSampling( 853 | message: z.infer["params"], 854 | ): Promise { 855 | return this.#server.createMessage(message); 856 | } 857 | 858 | public waitForReady(): Promise { 859 | if (this.isReady) { 860 | return Promise.resolve(); 861 | } 862 | 863 | if ( 864 | this.#connectionState === "error" || 865 | this.#connectionState === "closed" 866 | ) { 867 | return Promise.reject( 868 | new Error(`Connection is in ${this.#connectionState} state`), 869 | ); 870 | } 871 | 872 | return new Promise((resolve, reject) => { 873 | const timeout = setTimeout(() => { 874 | reject( 875 | new Error( 876 | "Connection timeout: Session failed to become ready within 5 seconds", 877 | ), 878 | ); 879 | }, 5000); 880 | 881 | this.once("ready", () => { 882 | clearTimeout(timeout); 883 | resolve(); 884 | }); 885 | 886 | this.once("error", (event) => { 887 | clearTimeout(timeout); 888 | reject(event.error); 889 | }); 890 | }); 891 | } 892 | 893 | #getPingConfig(transport: Transport): { 894 | enabled: boolean; 895 | intervalMs: number; 896 | logLevel: LoggingLevel; 897 | } { 898 | const pingConfig = this.#pingConfig || {}; 899 | 900 | let defaultEnabled = false; 901 | 902 | if ("type" in transport) { 903 | // Enable by default for SSE and HTTP streaming 904 | if (transport.type === "httpStream") { 905 | defaultEnabled = true; 906 | } 907 | } 908 | 909 | return { 910 | enabled: 911 | pingConfig.enabled !== undefined ? pingConfig.enabled : defaultEnabled, 912 | intervalMs: pingConfig.intervalMs || 5000, 913 | logLevel: pingConfig.logLevel || "debug", 914 | }; 915 | } 916 | 917 | private addPrompt(inputPrompt: InputPrompt) { 918 | const completers: Record = {}; 919 | const enums: Record = {}; 920 | 921 | for (const argument of inputPrompt.arguments ?? []) { 922 | if (argument.complete) { 923 | completers[argument.name] = argument.complete; 924 | } 925 | 926 | if (argument.enum) { 927 | enums[argument.name] = argument.enum; 928 | } 929 | } 930 | 931 | const prompt = { 932 | ...inputPrompt, 933 | complete: async (name: string, value: string) => { 934 | if (completers[name]) { 935 | return await completers[name](value); 936 | } 937 | 938 | if (enums[name]) { 939 | const fuse = new Fuse(enums[name], { 940 | keys: ["value"], 941 | }); 942 | 943 | const result = fuse.search(value); 944 | 945 | return { 946 | total: result.length, 947 | values: result.map((item) => item.item), 948 | }; 949 | } 950 | 951 | return { 952 | values: [], 953 | }; 954 | }, 955 | }; 956 | 957 | this.#prompts.push(prompt); 958 | } 959 | 960 | private addResource(inputResource: Resource) { 961 | this.#resources.push(inputResource); 962 | } 963 | 964 | private addResourceTemplate(inputResourceTemplate: InputResourceTemplate) { 965 | const completers: Record = {}; 966 | 967 | for (const argument of inputResourceTemplate.arguments ?? []) { 968 | if (argument.complete) { 969 | completers[argument.name] = argument.complete; 970 | } 971 | } 972 | 973 | const resourceTemplate = { 974 | ...inputResourceTemplate, 975 | complete: async (name: string, value: string) => { 976 | if (completers[name]) { 977 | return await completers[name](value); 978 | } 979 | 980 | return { 981 | values: [], 982 | }; 983 | }, 984 | }; 985 | 986 | this.#resourceTemplates.push(resourceTemplate); 987 | } 988 | 989 | private setupCompleteHandlers() { 990 | this.#server.setRequestHandler(CompleteRequestSchema, async (request) => { 991 | if (request.params.ref.type === "ref/prompt") { 992 | const prompt = this.#prompts.find( 993 | (prompt) => prompt.name === request.params.ref.name, 994 | ); 995 | 996 | if (!prompt) { 997 | throw new UnexpectedStateError("Unknown prompt", { 998 | request, 999 | }); 1000 | } 1001 | 1002 | if (!prompt.complete) { 1003 | throw new UnexpectedStateError("Prompt does not support completion", { 1004 | request, 1005 | }); 1006 | } 1007 | 1008 | const completion = CompletionZodSchema.parse( 1009 | await prompt.complete( 1010 | request.params.argument.name, 1011 | request.params.argument.value, 1012 | ), 1013 | ); 1014 | 1015 | return { 1016 | completion, 1017 | }; 1018 | } 1019 | 1020 | if (request.params.ref.type === "ref/resource") { 1021 | const resource = this.#resourceTemplates.find( 1022 | (resource) => resource.uriTemplate === request.params.ref.uri, 1023 | ); 1024 | 1025 | if (!resource) { 1026 | throw new UnexpectedStateError("Unknown resource", { 1027 | request, 1028 | }); 1029 | } 1030 | 1031 | if (!("uriTemplate" in resource)) { 1032 | throw new UnexpectedStateError("Unexpected resource"); 1033 | } 1034 | 1035 | if (!resource.complete) { 1036 | throw new UnexpectedStateError( 1037 | "Resource does not support completion", 1038 | { 1039 | request, 1040 | }, 1041 | ); 1042 | } 1043 | 1044 | const completion = CompletionZodSchema.parse( 1045 | await resource.complete( 1046 | request.params.argument.name, 1047 | request.params.argument.value, 1048 | ), 1049 | ); 1050 | 1051 | return { 1052 | completion, 1053 | }; 1054 | } 1055 | 1056 | throw new UnexpectedStateError("Unexpected completion request", { 1057 | request, 1058 | }); 1059 | }); 1060 | } 1061 | 1062 | private setupErrorHandling() { 1063 | this.#server.onerror = (error) => { 1064 | console.error("[FastMCP error]", error); 1065 | }; 1066 | } 1067 | 1068 | private setupLoggingHandlers() { 1069 | this.#server.setRequestHandler(SetLevelRequestSchema, (request) => { 1070 | this.#loggingLevel = request.params.level; 1071 | 1072 | return {}; 1073 | }); 1074 | } 1075 | 1076 | private setupPromptHandlers(prompts: Prompt[]) { 1077 | this.#server.setRequestHandler(ListPromptsRequestSchema, async () => { 1078 | return { 1079 | prompts: prompts.map((prompt) => { 1080 | return { 1081 | arguments: prompt.arguments, 1082 | complete: prompt.complete, 1083 | description: prompt.description, 1084 | name: prompt.name, 1085 | }; 1086 | }), 1087 | }; 1088 | }); 1089 | 1090 | this.#server.setRequestHandler(GetPromptRequestSchema, async (request) => { 1091 | const prompt = prompts.find( 1092 | (prompt) => prompt.name === request.params.name, 1093 | ); 1094 | 1095 | if (!prompt) { 1096 | throw new McpError( 1097 | ErrorCode.MethodNotFound, 1098 | `Unknown prompt: ${request.params.name}`, 1099 | ); 1100 | } 1101 | 1102 | const args = request.params.arguments; 1103 | 1104 | for (const arg of prompt.arguments ?? []) { 1105 | if (arg.required && !(args && arg.name in args)) { 1106 | throw new McpError( 1107 | ErrorCode.InvalidRequest, 1108 | `Prompt '${request.params.name}' requires argument '${arg.name}': ${arg.description || "No description provided"}`, 1109 | ); 1110 | } 1111 | } 1112 | 1113 | let result: Awaited>; 1114 | 1115 | try { 1116 | result = await prompt.load(args as Record); 1117 | } catch (error) { 1118 | const errorMessage = 1119 | error instanceof Error ? error.message : String(error); 1120 | throw new McpError( 1121 | ErrorCode.InternalError, 1122 | `Failed to load prompt '${request.params.name}': ${errorMessage}`, 1123 | ); 1124 | } 1125 | 1126 | return { 1127 | description: prompt.description, 1128 | messages: [ 1129 | { 1130 | content: { text: result, type: "text" }, 1131 | role: "user", 1132 | }, 1133 | ], 1134 | }; 1135 | }); 1136 | } 1137 | 1138 | private setupResourceHandlers(resources: Resource[]) { 1139 | this.#server.setRequestHandler(ListResourcesRequestSchema, async () => { 1140 | return { 1141 | resources: resources.map((resource) => { 1142 | return { 1143 | mimeType: resource.mimeType, 1144 | name: resource.name, 1145 | uri: resource.uri, 1146 | }; 1147 | }), 1148 | }; 1149 | }); 1150 | 1151 | this.#server.setRequestHandler( 1152 | ReadResourceRequestSchema, 1153 | async (request) => { 1154 | if ("uri" in request.params) { 1155 | const resource = resources.find( 1156 | (resource) => 1157 | "uri" in resource && resource.uri === request.params.uri, 1158 | ); 1159 | 1160 | if (!resource) { 1161 | for (const resourceTemplate of this.#resourceTemplates) { 1162 | const uriTemplate = parseURITemplate( 1163 | resourceTemplate.uriTemplate, 1164 | ); 1165 | 1166 | const match = uriTemplate.fromUri(request.params.uri); 1167 | 1168 | if (!match) { 1169 | continue; 1170 | } 1171 | 1172 | const uri = uriTemplate.fill(match); 1173 | 1174 | const result = await resourceTemplate.load(match); 1175 | 1176 | return { 1177 | contents: [ 1178 | { 1179 | mimeType: resourceTemplate.mimeType, 1180 | name: resourceTemplate.name, 1181 | uri: uri, 1182 | ...result, 1183 | }, 1184 | ], 1185 | }; 1186 | } 1187 | 1188 | throw new McpError( 1189 | ErrorCode.MethodNotFound, 1190 | `Resource not found: '${request.params.uri}'. Available resources: ${resources.map((r) => r.uri).join(", ") || "none"}`, 1191 | ); 1192 | } 1193 | 1194 | if (!("uri" in resource)) { 1195 | throw new UnexpectedStateError("Resource does not support reading"); 1196 | } 1197 | 1198 | let maybeArrayResult: Awaited>; 1199 | 1200 | try { 1201 | maybeArrayResult = await resource.load(); 1202 | } catch (error) { 1203 | const errorMessage = 1204 | error instanceof Error ? error.message : String(error); 1205 | throw new McpError( 1206 | ErrorCode.InternalError, 1207 | `Failed to load resource '${resource.name}' (${resource.uri}): ${errorMessage}`, 1208 | { 1209 | uri: resource.uri, 1210 | }, 1211 | ); 1212 | } 1213 | 1214 | if (Array.isArray(maybeArrayResult)) { 1215 | return { 1216 | contents: maybeArrayResult.map((result) => ({ 1217 | mimeType: resource.mimeType, 1218 | name: resource.name, 1219 | uri: resource.uri, 1220 | ...result, 1221 | })), 1222 | }; 1223 | } else { 1224 | return { 1225 | contents: [ 1226 | { 1227 | mimeType: resource.mimeType, 1228 | name: resource.name, 1229 | uri: resource.uri, 1230 | ...maybeArrayResult, 1231 | }, 1232 | ], 1233 | }; 1234 | } 1235 | } 1236 | 1237 | throw new UnexpectedStateError("Unknown resource request", { 1238 | request, 1239 | }); 1240 | }, 1241 | ); 1242 | } 1243 | 1244 | private setupResourceTemplateHandlers(resourceTemplates: ResourceTemplate[]) { 1245 | this.#server.setRequestHandler( 1246 | ListResourceTemplatesRequestSchema, 1247 | async () => { 1248 | return { 1249 | resourceTemplates: resourceTemplates.map((resourceTemplate) => { 1250 | return { 1251 | name: resourceTemplate.name, 1252 | uriTemplate: resourceTemplate.uriTemplate, 1253 | }; 1254 | }), 1255 | }; 1256 | }, 1257 | ); 1258 | } 1259 | 1260 | private setupRootsHandlers() { 1261 | if (this.#rootsConfig?.enabled === false) { 1262 | console.debug( 1263 | "[FastMCP debug] roots capability explicitly disabled via config", 1264 | ); 1265 | return; 1266 | } 1267 | 1268 | // Only set up roots notification handling if the server supports it 1269 | if (typeof this.#server.listRoots === "function") { 1270 | this.#server.setNotificationHandler( 1271 | RootsListChangedNotificationSchema, 1272 | () => { 1273 | this.#server 1274 | .listRoots() 1275 | .then((roots) => { 1276 | this.#roots = roots.roots; 1277 | 1278 | this.emit("rootsChanged", { 1279 | roots: roots.roots, 1280 | }); 1281 | }) 1282 | .catch((error) => { 1283 | if ( 1284 | error instanceof McpError && 1285 | error.code === ErrorCode.MethodNotFound 1286 | ) { 1287 | console.debug( 1288 | "[FastMCP debug] listRoots method not supported by client", 1289 | ); 1290 | } else { 1291 | console.error("[FastMCP error] Error listing roots", error); 1292 | } 1293 | }); 1294 | }, 1295 | ); 1296 | } else { 1297 | console.debug( 1298 | "[FastMCP debug] roots capability not available, not setting up notification handler", 1299 | ); 1300 | } 1301 | } 1302 | 1303 | private setupToolHandlers(tools: Tool[]) { 1304 | this.#server.setRequestHandler(ListToolsRequestSchema, async () => { 1305 | return { 1306 | tools: await Promise.all( 1307 | tools.map(async (tool) => { 1308 | return { 1309 | annotations: tool.annotations, 1310 | description: tool.description, 1311 | inputSchema: tool.parameters 1312 | ? await toJsonSchema(tool.parameters) 1313 | : { 1314 | additionalProperties: false, 1315 | properties: {}, 1316 | type: "object", 1317 | }, // More complete schema for Cursor compatibility 1318 | name: tool.name, 1319 | }; 1320 | }), 1321 | ), 1322 | }; 1323 | }); 1324 | 1325 | this.#server.setRequestHandler(CallToolRequestSchema, async (request) => { 1326 | const tool = tools.find((tool) => tool.name === request.params.name); 1327 | 1328 | if (!tool) { 1329 | throw new McpError( 1330 | ErrorCode.MethodNotFound, 1331 | `Unknown tool: ${request.params.name}`, 1332 | ); 1333 | } 1334 | 1335 | let args: unknown = undefined; 1336 | 1337 | if (tool.parameters) { 1338 | const parsed = await tool.parameters["~standard"].validate( 1339 | request.params.arguments, 1340 | ); 1341 | 1342 | if (parsed.issues) { 1343 | const friendlyErrors = parsed.issues 1344 | .map((issue) => { 1345 | const path = issue.path?.join(".") || "root"; 1346 | return `${path}: ${issue.message}`; 1347 | }) 1348 | .join(", "); 1349 | 1350 | throw new McpError( 1351 | ErrorCode.InvalidParams, 1352 | `Tool '${request.params.name}' parameter validation failed: ${friendlyErrors}`, 1353 | ); 1354 | } 1355 | 1356 | args = parsed.value; 1357 | } 1358 | 1359 | const progressToken = request.params?._meta?.progressToken; 1360 | 1361 | let result: ContentResult; 1362 | 1363 | try { 1364 | const reportProgress = async (progress: Progress) => { 1365 | await this.#server.notification({ 1366 | method: "notifications/progress", 1367 | params: { 1368 | ...progress, 1369 | progressToken, 1370 | }, 1371 | }); 1372 | }; 1373 | 1374 | const log = { 1375 | debug: (message: string, context?: SerializableValue) => { 1376 | this.#server.sendLoggingMessage({ 1377 | data: { 1378 | context, 1379 | message, 1380 | }, 1381 | level: "debug", 1382 | }); 1383 | }, 1384 | error: (message: string, context?: SerializableValue) => { 1385 | this.#server.sendLoggingMessage({ 1386 | data: { 1387 | context, 1388 | message, 1389 | }, 1390 | level: "error", 1391 | }); 1392 | }, 1393 | info: (message: string, context?: SerializableValue) => { 1394 | this.#server.sendLoggingMessage({ 1395 | data: { 1396 | context, 1397 | message, 1398 | }, 1399 | level: "info", 1400 | }); 1401 | }, 1402 | warn: (message: string, context?: SerializableValue) => { 1403 | this.#server.sendLoggingMessage({ 1404 | data: { 1405 | context, 1406 | message, 1407 | }, 1408 | level: "warning", 1409 | }); 1410 | }, 1411 | }; 1412 | 1413 | // Create a promise for tool execution 1414 | // Streams partial results while a tool is still executing 1415 | // Enables progressive rendering and real-time feedback 1416 | const streamContent = async (content: Content | Content[]) => { 1417 | const contentArray = Array.isArray(content) ? content : [content]; 1418 | 1419 | await this.#server.notification({ 1420 | method: "notifications/tool/streamContent", 1421 | params: { 1422 | content: contentArray, 1423 | toolName: request.params.name, 1424 | }, 1425 | }); 1426 | }; 1427 | 1428 | const executeToolPromise = tool.execute(args, { 1429 | log, 1430 | reportProgress, 1431 | session: this.#auth, 1432 | streamContent, 1433 | }); 1434 | 1435 | // Handle timeout if specified 1436 | const maybeStringResult = (await (tool.timeoutMs 1437 | ? Promise.race([ 1438 | executeToolPromise, 1439 | new Promise((_, reject) => { 1440 | setTimeout(() => { 1441 | reject( 1442 | new UserError( 1443 | `Tool '${request.params.name}' timed out after ${tool.timeoutMs}ms. Consider increasing timeoutMs or optimizing the tool implementation.`, 1444 | ), 1445 | ); 1446 | }, tool.timeoutMs); 1447 | }), 1448 | ]) 1449 | : executeToolPromise)) as 1450 | | AudioContent 1451 | | ContentResult 1452 | | ImageContent 1453 | | null 1454 | | ResourceContent 1455 | | string 1456 | | TextContent 1457 | | undefined; 1458 | 1459 | if (maybeStringResult === undefined || maybeStringResult === null) { 1460 | result = ContentResultZodSchema.parse({ 1461 | content: [], 1462 | }); 1463 | } else if (typeof maybeStringResult === "string") { 1464 | result = ContentResultZodSchema.parse({ 1465 | content: [{ text: maybeStringResult, type: "text" }], 1466 | }); 1467 | } else if ("type" in maybeStringResult) { 1468 | result = ContentResultZodSchema.parse({ 1469 | content: [maybeStringResult], 1470 | }); 1471 | } else { 1472 | result = ContentResultZodSchema.parse(maybeStringResult); 1473 | } 1474 | } catch (error) { 1475 | if (error instanceof UserError) { 1476 | return { 1477 | content: [{ text: error.message, type: "text" }], 1478 | isError: true, 1479 | }; 1480 | } 1481 | 1482 | const errorMessage = 1483 | error instanceof Error ? error.message : String(error); 1484 | return { 1485 | content: [ 1486 | { 1487 | text: `Tool '${request.params.name}' execution failed: ${errorMessage}`, 1488 | type: "text", 1489 | }, 1490 | ], 1491 | isError: true, 1492 | }; 1493 | } 1494 | 1495 | return result; 1496 | }); 1497 | } 1498 | } 1499 | 1500 | const FastMCPEventEmitterBase: { 1501 | new (): StrictEventEmitter>; 1502 | } = EventEmitter; 1503 | 1504 | type Authenticate = (request: http.IncomingMessage) => Promise; 1505 | 1506 | class FastMCPEventEmitter extends FastMCPEventEmitterBase {} 1507 | 1508 | export class FastMCP< 1509 | T extends Record | undefined = undefined, 1510 | > extends FastMCPEventEmitter { 1511 | public get sessions(): FastMCPSession[] { 1512 | return this.#sessions; 1513 | } 1514 | #authenticate: Authenticate | undefined; 1515 | #httpStreamServer: null | SSEServer = null; 1516 | #options: ServerOptions; 1517 | #prompts: InputPrompt[] = []; 1518 | #resources: Resource[] = []; 1519 | #resourcesTemplates: InputResourceTemplate[] = []; 1520 | #sessions: FastMCPSession[] = []; 1521 | 1522 | #tools: Tool[] = []; 1523 | 1524 | constructor(public options: ServerOptions) { 1525 | super(); 1526 | 1527 | this.#options = options; 1528 | this.#authenticate = options.authenticate; 1529 | } 1530 | 1531 | /** 1532 | * Adds a prompt to the server. 1533 | */ 1534 | public addPrompt( 1535 | prompt: InputPrompt, 1536 | ) { 1537 | this.#prompts.push(prompt); 1538 | } 1539 | 1540 | /** 1541 | * Adds a resource to the server. 1542 | */ 1543 | public addResource(resource: Resource) { 1544 | this.#resources.push(resource); 1545 | } 1546 | 1547 | /** 1548 | * Adds a resource template to the server. 1549 | */ 1550 | public addResourceTemplate< 1551 | const Args extends InputResourceTemplateArgument[], 1552 | >(resource: InputResourceTemplate) { 1553 | this.#resourcesTemplates.push(resource); 1554 | } 1555 | 1556 | /** 1557 | * Adds a tool to the server. 1558 | */ 1559 | public addTool(tool: Tool) { 1560 | this.#tools.push(tool as unknown as Tool); 1561 | } 1562 | 1563 | /** 1564 | * Embeds a resource by URI, making it easy to include resources in tool responses. 1565 | * 1566 | * @param uri - The URI of the resource to embed 1567 | * @returns Promise - The embedded resource content 1568 | */ 1569 | public async embedded(uri: string): Promise { 1570 | // First, try to find a direct resource match 1571 | const directResource = this.#resources.find( 1572 | (resource) => resource.uri === uri, 1573 | ); 1574 | 1575 | if (directResource) { 1576 | const result = await directResource.load(); 1577 | const results = Array.isArray(result) ? result : [result]; 1578 | const firstResult = results[0]; 1579 | 1580 | const resourceData: ResourceContent["resource"] = { 1581 | mimeType: directResource.mimeType, 1582 | uri, 1583 | }; 1584 | 1585 | if ("text" in firstResult) { 1586 | resourceData.text = firstResult.text; 1587 | } 1588 | 1589 | if ("blob" in firstResult) { 1590 | resourceData.blob = firstResult.blob; 1591 | } 1592 | 1593 | return resourceData; 1594 | } 1595 | 1596 | // Try to match against resource templates 1597 | for (const template of this.#resourcesTemplates) { 1598 | // Check if the URI starts with the template base 1599 | const templateBase = template.uriTemplate.split("{")[0]; 1600 | 1601 | if (uri.startsWith(templateBase)) { 1602 | const params: Record = {}; 1603 | const templateParts = template.uriTemplate.split("/"); 1604 | const uriParts = uri.split("/"); 1605 | 1606 | for (let i = 0; i < templateParts.length; i++) { 1607 | const templatePart = templateParts[i]; 1608 | 1609 | if (templatePart?.startsWith("{") && templatePart.endsWith("}")) { 1610 | const paramName = templatePart.slice(1, -1); 1611 | const paramValue = uriParts[i]; 1612 | 1613 | if (paramValue) { 1614 | params[paramName] = paramValue; 1615 | } 1616 | } 1617 | } 1618 | 1619 | const result = await template.load( 1620 | params as ResourceTemplateArgumentsToObject< 1621 | typeof template.arguments 1622 | >, 1623 | ); 1624 | 1625 | const resourceData: ResourceContent["resource"] = { 1626 | mimeType: template.mimeType, 1627 | uri, 1628 | }; 1629 | 1630 | if ("text" in result) { 1631 | resourceData.text = result.text; 1632 | } 1633 | 1634 | if ("blob" in result) { 1635 | resourceData.blob = result.blob; 1636 | } 1637 | 1638 | return resourceData; // The resource we're looking for 1639 | } 1640 | } 1641 | 1642 | throw new UnexpectedStateError(`Resource not found: ${uri}`, { uri }); 1643 | } 1644 | 1645 | /** 1646 | * Starts the server. 1647 | */ 1648 | public async start( 1649 | options: 1650 | | { 1651 | httpStream: { port: number }; 1652 | transportType: "httpStream"; 1653 | } 1654 | | { transportType: "stdio" } = { 1655 | transportType: "stdio", 1656 | }, 1657 | ) { 1658 | if (options.transportType === "stdio") { 1659 | const transport = new StdioServerTransport(); 1660 | 1661 | const session = new FastMCPSession({ 1662 | instructions: this.#options.instructions, 1663 | name: this.#options.name, 1664 | ping: this.#options.ping, 1665 | prompts: this.#prompts, 1666 | resources: this.#resources, 1667 | resourcesTemplates: this.#resourcesTemplates, 1668 | roots: this.#options.roots, 1669 | tools: this.#tools, 1670 | version: this.#options.version, 1671 | }); 1672 | 1673 | await session.connect(transport); 1674 | 1675 | this.#sessions.push(session); 1676 | 1677 | this.emit("connect", { 1678 | session, 1679 | }); 1680 | } else if (options.transportType === "httpStream") { 1681 | this.#httpStreamServer = await startHTTPServer>({ 1682 | createServer: async (request) => { 1683 | let auth: T | undefined; 1684 | 1685 | if (this.#authenticate) { 1686 | auth = await this.#authenticate(request); 1687 | } 1688 | 1689 | return new FastMCPSession({ 1690 | auth, 1691 | name: this.#options.name, 1692 | ping: this.#options.ping, 1693 | prompts: this.#prompts, 1694 | resources: this.#resources, 1695 | resourcesTemplates: this.#resourcesTemplates, 1696 | roots: this.#options.roots, 1697 | tools: this.#tools, 1698 | version: this.#options.version, 1699 | }); 1700 | }, 1701 | onClose: (session) => { 1702 | this.emit("disconnect", { 1703 | session, 1704 | }); 1705 | }, 1706 | onConnect: async (session) => { 1707 | this.#sessions.push(session); 1708 | 1709 | this.emit("connect", { 1710 | session, 1711 | }); 1712 | }, 1713 | onUnhandledRequest: async (req, res) => { 1714 | const healthConfig = this.#options.health ?? {}; 1715 | 1716 | const enabled = 1717 | healthConfig.enabled === undefined ? true : healthConfig.enabled; 1718 | 1719 | if (enabled) { 1720 | const path = healthConfig.path ?? "/health"; 1721 | const url = new URL(req.url || "", "http://localhost"); 1722 | 1723 | try { 1724 | if (req.method === "GET" && url.pathname === path) { 1725 | res 1726 | .writeHead(healthConfig.status ?? 200, { 1727 | "Content-Type": "text/plain", 1728 | }) 1729 | .end(healthConfig.message ?? "ok"); 1730 | 1731 | return; 1732 | } 1733 | 1734 | // Enhanced readiness check endpoint 1735 | if (req.method === "GET" && url.pathname === "/ready") { 1736 | const readySessions = this.#sessions.filter( 1737 | (s) => s.isReady, 1738 | ).length; 1739 | const totalSessions = this.#sessions.length; 1740 | const allReady = 1741 | readySessions === totalSessions && totalSessions > 0; 1742 | 1743 | const response = { 1744 | ready: readySessions, 1745 | status: allReady 1746 | ? "ready" 1747 | : totalSessions === 0 1748 | ? "no_sessions" 1749 | : "initializing", 1750 | total: totalSessions, 1751 | }; 1752 | 1753 | res 1754 | .writeHead(allReady ? 200 : 503, { 1755 | "Content-Type": "application/json", 1756 | }) 1757 | .end(JSON.stringify(response)); 1758 | 1759 | return; 1760 | } 1761 | } catch (error) { 1762 | console.error("[FastMCP error] health endpoint error", error); 1763 | } 1764 | } 1765 | 1766 | // If the request was not handled above, return 404 1767 | res.writeHead(404).end(); 1768 | }, 1769 | port: options.httpStream.port, 1770 | }); 1771 | 1772 | console.info( 1773 | `[FastMCP info] server is running on HTTP Stream at http://localhost:${options.httpStream.port}/stream`, 1774 | ); 1775 | } else { 1776 | throw new Error("Invalid transport type"); 1777 | } 1778 | } 1779 | 1780 | /** 1781 | * Stops the server. 1782 | */ 1783 | public async stop() { 1784 | if (this.#httpStreamServer) { 1785 | await this.#httpStreamServer.close(); 1786 | } 1787 | } 1788 | } 1789 | 1790 | export type { Context }; 1791 | export type { Tool, ToolParameters }; 1792 | export type { Content, ContentResult, ImageContent, TextContent }; 1793 | export type { Progress, SerializableValue }; 1794 | export type { Resource, ResourceResult }; 1795 | export type { ResourceTemplate, ResourceTemplateArgument }; 1796 | export type { Prompt, PromptArgument }; 1797 | export type { InputPrompt, InputPromptArgument }; 1798 | export type { LoggingLevel, ServerOptions }; 1799 | export type { FastMCPEvents, FastMCPSessionEvents }; 1800 | -------------------------------------------------------------------------------- /src/bin/fastmcp.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { execa } from "execa"; 4 | import yargs from "yargs"; 5 | import { hideBin } from "yargs/helpers"; 6 | 7 | await yargs(hideBin(process.argv)) 8 | .scriptName("fastmcp") 9 | .command( 10 | "dev ", 11 | "Start a development server", 12 | (yargs) => { 13 | return yargs.positional("file", { 14 | demandOption: true, 15 | describe: "The path to the server file", 16 | type: "string", 17 | }); 18 | }, 19 | async (argv) => { 20 | try { 21 | await execa({ 22 | stderr: "inherit", 23 | stdin: "inherit", 24 | stdout: "inherit", 25 | })`npx @wong2/mcp-cli npx tsx ${argv.file}`; 26 | } catch (error) { 27 | console.error( 28 | "[FastMCP Error] Failed to start development server:", 29 | error instanceof Error ? error.message : String(error), 30 | ); 31 | process.exit(1); 32 | } 33 | }, 34 | ) 35 | .command( 36 | "inspect ", 37 | "Inspect a server file", 38 | (yargs) => { 39 | return yargs.positional("file", { 40 | demandOption: true, 41 | describe: "The path to the server file", 42 | type: "string", 43 | }); 44 | }, 45 | async (argv) => { 46 | try { 47 | await execa({ 48 | stderr: "inherit", 49 | stdout: "inherit", 50 | })`npx @modelcontextprotocol/inspector npx tsx ${argv.file}`; 51 | } catch (error) { 52 | console.error( 53 | "[FastMCP Error] Failed to inspect server:", 54 | error instanceof Error ? error.message : String(error), 55 | ); 56 | process.exit(1); 57 | } 58 | }, 59 | ) 60 | .help() 61 | .parseAsync(); 62 | -------------------------------------------------------------------------------- /src/examples/addition.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Example FastMCP server demonstrating core functionality plus streaming output. 3 | * 4 | * Features demonstrated: 5 | * - Basic tool with type-safe parameters 6 | * - Streaming-enabled tool for incremental output 7 | * - Advanced tool annotations 8 | * 9 | * For a complete project template, see https://github.com/punkpeye/fastmcp-boilerplate 10 | */ 11 | import { type } from "arktype"; 12 | import * as v from "valibot"; 13 | import { z } from "zod"; 14 | 15 | import { FastMCP } from "../FastMCP.js"; 16 | 17 | const server = new FastMCP({ 18 | name: "Addition", 19 | ping: { 20 | // enabled: undefined, 21 | // Automatically enabled/disabled based on transport type 22 | // Using a longer interval to reduce log noise 23 | intervalMs: 10000, // default is 5000ms 24 | // Reduce log verbosity 25 | logLevel: "debug", // default 26 | }, 27 | roots: { 28 | // You can explicitly disable roots support if needed 29 | // enabled: false, 30 | }, 31 | version: "1.0.0", 32 | }); 33 | 34 | // --- Zod Example --- 35 | const AddParamsZod = z.object({ 36 | a: z.number().describe("The first number"), 37 | b: z.number().describe("The second number"), 38 | }); 39 | 40 | server.addTool({ 41 | annotations: { 42 | openWorldHint: false, // This tool doesn't interact with external systems 43 | readOnlyHint: true, // This tool doesn't modify anything 44 | title: "Addition (Zod)", 45 | }, 46 | description: "Add two numbers (using Zod schema)", 47 | execute: async (args) => { 48 | // args is typed as { a: number, b: number } 49 | console.log(`[Zod] Adding ${args.a} and ${args.b}`); 50 | return String(args.a + args.b); 51 | }, 52 | name: "add-zod", 53 | parameters: AddParamsZod, 54 | }); 55 | 56 | // --- ArkType Example --- 57 | const AddParamsArkType = type({ 58 | a: "number", 59 | b: "number", 60 | }); 61 | 62 | server.addTool({ 63 | annotations: { 64 | destructiveHint: true, // This would perform destructive operations 65 | idempotentHint: true, // But operations can be repeated safely 66 | openWorldHint: true, // Interacts with external systems 67 | readOnlyHint: false, // Example showing a modifying tool 68 | title: "Addition (ArkType)", 69 | }, 70 | description: "Add two numbers (using ArkType schema)", 71 | execute: async (args, { log }) => { 72 | // args is typed as { a: number, b: number } based on AddParamsArkType.infer 73 | console.log(`[ArkType] Adding ${args.a} and ${args.b}`); 74 | 75 | // Demonstrate long-running operation that might need a timeout 76 | log.info("Starting calculation with potential delay..."); 77 | 78 | // Simulate a complex calculation process 79 | if (args.a > 1000 || args.b > 1000) { 80 | log.warn("Large numbers detected, operation might take longer"); 81 | // In a real implementation, this delay might be a slow operation 82 | await new Promise((resolve) => setTimeout(resolve, 3000)); 83 | } 84 | 85 | return String(args.a + args.b); 86 | }, 87 | name: "add-arktype", 88 | parameters: AddParamsArkType, 89 | // Will abort execution after 2s 90 | timeoutMs: 2000, 91 | }); 92 | 93 | // --- Valibot Example --- 94 | const AddParamsValibot = v.object({ 95 | a: v.number("The first number"), 96 | b: v.number("The second number"), 97 | }); 98 | 99 | server.addTool({ 100 | annotations: { 101 | openWorldHint: false, 102 | readOnlyHint: true, 103 | title: "Addition (Valibot)", 104 | }, 105 | description: "Add two numbers (using Valibot schema)", 106 | execute: async (args) => { 107 | console.log(`[Valibot] Adding ${args.a} and ${args.b}`); 108 | return String(args.a + args.b); 109 | }, 110 | name: "add-valibot", 111 | parameters: AddParamsValibot, 112 | }); 113 | 114 | server.addResource({ 115 | async load() { 116 | return { 117 | text: "Example log content", 118 | }; 119 | }, 120 | mimeType: "text/plain", 121 | name: "Application Logs", 122 | uri: "file:///logs/app.log", 123 | }); 124 | 125 | server.addTool({ 126 | annotations: { 127 | openWorldHint: false, 128 | readOnlyHint: true, 129 | streamingHint: true, 130 | }, 131 | description: "Generate a poem line by line with streaming output", 132 | execute: async (args, context) => { 133 | const { theme } = args; 134 | const lines = [ 135 | `Poem about ${theme} - line 1`, 136 | `Poem about ${theme} - line 2`, 137 | `Poem about ${theme} - line 3`, 138 | `Poem about ${theme} - line 4`, 139 | ]; 140 | 141 | for (const line of lines) { 142 | await context.streamContent({ 143 | text: line, 144 | type: "text", 145 | }); 146 | 147 | await new Promise((resolve) => setTimeout(resolve, 1000)); 148 | } 149 | 150 | return; 151 | }, 152 | name: "stream-poem", 153 | parameters: z.object({ 154 | theme: z.string().describe("Theme for the poem"), 155 | }), 156 | }); 157 | 158 | server.addPrompt({ 159 | arguments: [ 160 | { 161 | description: "Git diff or description of changes", 162 | name: "changes", 163 | required: true, 164 | }, 165 | ], 166 | description: "Generate a Git commit message", 167 | load: async (args) => { 168 | return `Generate a concise but descriptive commit message for these changes:\n\n${args.changes}`; 169 | }, 170 | name: "git-commit", 171 | }); 172 | 173 | server.addResourceTemplate({ 174 | arguments: [ 175 | { 176 | description: "Documentation section to retrieve", 177 | name: "section", 178 | required: true, 179 | }, 180 | ], 181 | description: "Get project documentation", 182 | load: async (args) => { 183 | const docs = { 184 | "api-reference": 185 | "# API Reference\n\n## Authentication\nAll API requests require a valid API key in the Authorization header.\n\n## Endpoints\n- GET /users - List all users\n- POST /users - Create new user", 186 | deployment: 187 | "# Deployment Guide\n\nTo deploy this application:\n\n1. Build the project: `npm run build`\n2. Set environment variables\n3. Deploy to your hosting platform", 188 | "getting-started": 189 | "# Getting Started\n\nWelcome to our project! Follow these steps to set up your development environment:\n\n1. Clone the repository\n2. Install dependencies with `npm install`\n3. Run `npm start` to begin", 190 | }; 191 | 192 | return { 193 | text: 194 | docs[args.section as keyof typeof docs] || 195 | "Documentation section not found", 196 | }; 197 | }, 198 | mimeType: "text/markdown", 199 | name: "Project Documentation", 200 | uriTemplate: "docs://project/{section}", 201 | }); 202 | 203 | server.addTool({ 204 | annotations: { 205 | openWorldHint: false, 206 | readOnlyHint: true, 207 | title: "Get Documentation (Embedded)", 208 | }, 209 | description: 210 | "Retrieve project documentation using embedded resources - demonstrates the new embedded() feature", 211 | execute: async (args) => { 212 | return { 213 | content: [ 214 | { 215 | resource: await server.embedded(`docs://project/${args.section}`), 216 | type: "resource", 217 | }, 218 | ], 219 | }; 220 | }, 221 | name: "get-documentation", 222 | parameters: z.object({ 223 | section: z 224 | .enum(["getting-started", "api-reference", "deployment"]) 225 | .describe("Documentation section to retrieve"), 226 | }), 227 | }); 228 | 229 | // Select transport type based on command line arguments 230 | const transportType = process.argv.includes("--http-stream") 231 | ? "httpStream" 232 | : "stdio"; 233 | 234 | if (transportType === "httpStream") { 235 | // Start with HTTP streaming transport 236 | const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 8080; 237 | 238 | server.start({ 239 | httpStream: { 240 | port: PORT, 241 | }, 242 | transportType: "httpStream", 243 | }); 244 | 245 | console.log( 246 | `HTTP Stream MCP server is running at http://localhost:${PORT}/stream`, 247 | ); 248 | console.log("Use StreamableHTTPClientTransport to connect to this server"); 249 | console.log("For example:"); 250 | console.log(` 251 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; 252 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; 253 | 254 | const client = new Client( 255 | { 256 | name: "example-client", 257 | version: "1.0.0", 258 | }, 259 | { 260 | capabilities: {}, 261 | }, 262 | ); 263 | 264 | const transport = new StreamableHTTPClientTransport( 265 | new URL("http://localhost:${PORT}/stream"), 266 | ); 267 | 268 | await client.connect(transport); 269 | `); 270 | } else if (process.argv.includes("--explicit-ping-config")) { 271 | server.start({ 272 | transportType: "stdio", 273 | }); 274 | 275 | console.log( 276 | "Started stdio transport with explicit ping configuration from server options", 277 | ); 278 | } else if (process.argv.includes("--disable-roots")) { 279 | // Example of disabling roots at runtime 280 | const serverWithDisabledRoots = new FastMCP({ 281 | name: "Addition (No Roots)", 282 | ping: { 283 | intervalMs: 10000, 284 | logLevel: "debug", 285 | }, 286 | roots: { 287 | enabled: false, 288 | }, 289 | version: "1.0.0", 290 | }); 291 | 292 | serverWithDisabledRoots.start({ 293 | transportType: "stdio", 294 | }); 295 | 296 | console.log("Started stdio transport with roots support disabled"); 297 | } else { 298 | // Disable by default for: 299 | server.start({ 300 | transportType: "stdio", 301 | }); 302 | 303 | console.log("Started stdio transport with ping disabled by default"); 304 | } 305 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node22/tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": true, 5 | "noUnusedLocals": true, 6 | "noUnusedParameters": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /vitest.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | poolOptions: { 6 | forks: { execArgv: ["--experimental-eventsource"] }, 7 | }, 8 | }, 9 | }); 10 | --------------------------------------------------------------------------------