# Add an MCP Server to a Remix v3 App

Used: remix@3.0.0 - @sdxc/mcp@2026.10.9 - @sdxc/json-schema@2026.10.6

If you have an app with data your users care about, sooner or later someone will want to use it from an AI agent, they want to ask Claude, ChatGPT or Cursor to search their notes instead of opening your app and doing it themselves. The way those agents talk to your app is the Model Context Protocol, or MCP.

The latest revision of MCP made the protocol stateless, so there's no handshake or session to keep around anymore, every call is a request and a response. And in Remix v3 a request and a response is just a route, so we can add an MCP server to our app the same way we add any other route.

## Create the Notes

First we need something for the MCP server to expose, so let's create a list of notes and a function to search them.

```ts {% path="app/notes.ts" %}
export interface Note {
	id: string;
	title: string;
	body: string;
}

const NOTES: Note[] = [
	{ id: "1", title: "Remix v3 routes", body: "Routes are contracts declared with remix/routes." },
	{ id: "2", title: "MCP tools", body: "A tool is something a model can call." },
	{ id: "3", title: "Groceries", body: "Milk, eggs and coffee." },
];

export function searchNotes(query: string, limit: number) {
	let needle = query.toLowerCase();
	return NOTES.filter((note) =>
		`${note.title} ${note.body}`.toLowerCase().includes(needle),
	).slice(0, limit);
}
```

In a real app this would probably query a database, but the MCP part doesn't care where the notes come from.

## Declare the Tool

A tool is something the model can call. We declare it with a name, a description, and a schema for its arguments.

```ts {% path="app/mcp/tools.ts" %}
import * as s from "@sdxc/json-schema";
import * as checks from "@sdxc/json-schema/checks";
import { tool, tools } from "@sdxc/mcp";

export default tools({
	searchNotes: tool("search_notes", {
		title: "Search notes",
		description: "Search notes by title and body. Returns the id, title and body of each match.",
		input: s.object({
			query: s
				.string()
				.pipe(checks.minLength(1))
				.meta({ description: "Words to look for in the title and body." }),
			limit: s.defaulted(
				s
					.integer()
					.pipe(checks.min(1), checks.max(50))
					.meta({ description: "How many notes to return." }),
				10,
			),
		}),
		annotations: { readOnlyHint: true },
	}),
});
```

The schema uses `@sdxc/json-schema`, which has the same API as `remix/data-schema` but can also turn the schema into JSON Schema. That's what the MCP client receives when it asks for the list of tools, and it's also what we use to validate the arguments the model sends, so we only declare them once.

The description is what the model reads to decide if it should call the tool, so think of it as a prompt. And the `readOnlyHint` lets the client know the tool doesn't change anything, so it can call it without asking the user first.

## Create the MCP Handler

Now we can create the MCP handler and map the tool to a function, similar to how we map a route to a controller.

```ts {% path="app/mcp/server.ts" %}
import { createHandler, ToolError } from "@sdxc/mcp";

import { searchNotes } from "../notes";
import toolset from "./tools";

export let mcp = createHandler({
	name: "notes",
	title: "Notes",
	version: "1.0.0",
	instructions: "Search the user's notes with search_notes.",
});

mcp.tools.map(toolset.searchNotes, (ctx) => {
	let notes = searchNotes(ctx.input.query, ctx.input.limit);
	if (notes.length === 0) throw new ToolError("No note matched. Try fewer or broader words.");
	return notes;
});
```

Here `ctx.input` has the arguments already validated and typed from the schema, so `ctx.input.limit` is a `number` and if the model didn't send it we get the default `10`. Whatever we return is sent back to the model, as JSON in this case since it's an array, if we returned a string it would be sent as is.

If there are no notes we throw a `ToolError`. The message of a `ToolError` is sent to the model, so we can use it to tell the model what to do next. Any other error is hidden from the model, so we don't leak something we didn't mean to.

## Connect It to the Router

The MCP server needs a URL, so let's add a route for it. MCP clients only send `POST` requests, so we use `post`.

```ts {% path="app/routes.ts" %}
import { post, route } from "remix/routes";

export let routes = route({
	mcp: post("/mcp"),
});
```

Then we map that route to the MCP handler.

```ts {% path="app/router.ts" %}
import { createRouter } from "remix/router";

import { mcp } from "./mcp/server";
import { routes } from "./routes";

export let router = createRouter();

router.map(routes.mcp, (ctx) => mcp.fetch(ctx));
```

We pass the whole `ctx` to `mcp.fetch` instead of only the request. This way, if later we add a middleware to the router, like one for authentication or to connect to a database, whatever it sets in the context will be available in the tool handler too.

Finally, we export the router from a Cloudflare Worker.

```ts {% path="app/worker.ts" %}
import { router } from "./router";

export default {
	fetch(request: Request) {
		return router.fetch(request);
	},
};
```

And point Wrangler to that file.

```jsonc {% path="wrangler.jsonc" %}
{
	"name": "notes-mcp",
	"main": "app/worker.ts",
	"compatibility_date": "2026-10-01"
}
```

## Call the Tool

Let's run `npx wrangler dev` and call the tool with `curl`. An MCP client would send this request for us, but doing it by hand lets us see what's actually sent.

```bash
curl http://localhost:8787/mcp \
  -H "Content-Type: application/json" \
  -H "MCP-Protocol-Version: 2026-07-28" \
  -H "Mcp-Method: tools/call" \
  -H "Mcp-Name: search_notes" \
  -d '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "tools/call",
    "params": {
      "name": "search_notes",
      "arguments": { "query": "remix" },
      "_meta": {
        "io.modelcontextprotocol/protocolVersion": "2026-07-28",
        "io.modelcontextprotocol/clientCapabilities": {}
      }
    }
  }'
```

Because there's no handshake, the request has to include the protocol version and the client capabilities every time, that's the `_meta` object. The method and tool name are also sent as headers, so a proxy can route the request without reading the body.

The server will respond with the note that matched.

```json
{
	"jsonrpc": "2.0",
	"id": 1,
	"result": {
		"resultType": "complete",
		"content": [
			{
				"type": "text",
				"text": "[\n  {\n    \"id\": \"1\",\n    \"title\": \"Remix v3 routes\",\n    \"body\": \"Routes are contracts declared with remix/routes.\"\n  }\n]"
			}
		]
	}
}
```

And if we search for something that doesn't exist, like `"vue"`, we will get our `ToolError` message back with `"isError": true`.

And that's it, we have an MCP server running in our Remix app. From here we could add more tools, or protect the route with an API key using a normal Remix middleware.

