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.
// app/notes.tsexport 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.
// app/mcp/tools.tsimport * 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.
// app/mcp/server.tsimport { 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.
// app/routes.tsimport { post, route } from "remix/routes"; export let routes = route({ mcp: post("/mcp"), });
Then we map that route to the MCP handler.
// app/router.tsimport { 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.
// app/worker.tsimport { router } from "./router"; export default { fetch(request: Request) { return router.fetch(request); }, };
And point Wrangler to that file.
// 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.
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.
{
"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.