MCP server
A Model Context Protocol (MCP) server for the Dox framework.
It serves tools to MCP clients (Claude, ChatGPT, IDE agents) over the Streamable HTTP transport, from a normal Dox route.
- Tool arguments are
dox_validationschemas: clients see them as JSON Schema, and every call is checked before your code runs. - Session ids are signed with
APP_KEY, so a session made on one isolate works on every isolate, and after a restart. No store is needed. - Replies are plain JSON. No SSE stream is needed for tools.
Install
Use
Write a tool:
import 'package:dox_core/dox_core.dart';
import 'package:dox_mcp/dox_mcp.dart';
class AddExpense extends McpTool {
@override
String get name => 'add_expense';
@override
String get description => 'Record an expense. Amount is in dollars.';
@override
ObjectSchema get input => v.object(<String, Schema<Object?>>{
'amount': v.num().positive().describe('Amount, like 12.50.'),
'note': v.string().max(500).optional(),
});
@override
Future<McpToolResult> call(Map<String, dynamic> args, McpContext context) async {
User user = context.authAs<User>();
// ... save it
return McpToolResult.text('Recorded ${args['amount']} for ${user.email}.');
}
}
Serve it on a route. Find the user first (from a token, a header or the URL), and pass it as auth. scope ties each session to that user:
final McpServer mcp = McpServer(
name: 'my-app',
version: '1.0.0',
tools: <McpTool>[AddExpense()],
);
Route.match(<String>['GET', 'POST', 'DELETE'], '/mcp/{token}', (DoxRequest req) async {
User? user = await User().where('mcp_token', req.param['token']).getFirst();
if (user == null) return response('Invalid token').statusCode(401);
return mcp.handle(req, auth: user, scope: 'user:${user.id}');
});
A small tool can be a function:
McpTool.of(
name: 'echo',
description: 'Say the text back.',
input: v.object(<String, Schema<Object?>>{'text': v.string()}),
handler: (Map<String, dynamic> args, McpContext context) {
return McpToolResult.text(args['text'] as String);
},
);
Tools that take a DTO
A tool can take a @Dto class as its input. Extend McpDtoTool<T>. The rules of the DTO (like @Length and @HexColor) are written once, and handle gets the typed object, not a map.
Use @Describe on a field to explain it to the model. It goes into the JSON Schema as description. It does not change the checks.
@Dto()
class NewCategory {
@Describe('The category name, like Food.')
@Length(min: 1, max: 50)
final String name;
@Describe('A color, like #1a2b3c.')
@HexColor()
final String color;
const NewCategory({required this.name, required this.color});
static const DtoSchema<NewCategory> schema = $NewCategoryDto;
}
class CreateCategory extends McpDtoTool<NewCategory> {
@override
String get name => 'create_category';
@override
String get description => 'Add a category.';
@override
DtoSchema<NewCategory> get dto => NewCategory.schema;
@override
Future<McpToolResult> handle(NewCategory input, McpContext context) async {
// ... save it
return McpToolResult.text('Added ${input.name}.');
}
}
tools/listsends the JSON Schema of the DTO (toJsonSchema()) asinputSchema.- The arguments are checked like a DTO in a controller, with async rules like
@Uniqueand@Existstoo. - Bad arguments are a tool result with
isError: true, not a JSON-RPC error. The text lists each problem, so the model can fix the call:
Input validation error: Invalid arguments for tool create_category: name: The name is required; color: The color must be a hex color like #1a2b3c
What the server answers
| Request | Answer |
|---|---|
POST initialize |
200 with the server info and an Mcp-Session-Id header |
POST a request with a valid Mcp-Session-Id |
200 with the JSON-RPC response (a list for a batch) |
POST only notifications or responses |
202, no body |
POST with no session id |
400 |
POST with an unknown session id, or one of another scope |
404, so the client starts a new session |
POST with an ended session id |
404, only when SignedMcpSessions has an ended store. Without it, the id still works |
POST with a body that is not valid JSON |
400 with the JSON-RPC error -32700 |
POST with an empty batch, or a message that is not JSON-RPC 2.0 |
400 with the JSON-RPC error -32600 |
POST with initialize in a batch with other messages |
400 with the JSON-RPC error -32600. Send initialize alone |
POST with an unsupported MCP-Protocol-Version header |
400 |
POST with another Content-Type than JSON |
415 |
Any request with an Origin header that is not in allowedOrigins |
403 |
GET |
405: there is no server-to-client stream |
DELETE with a valid session id |
200. The session ends only if SignedMcpSessions has an ended store (see Sessions) |
DELETE with no session id |
400 |
DELETE with an unknown session id, or one of another scope |
404. An ended id also gets 404 when there is an ended store |
Methods: initialize, ping, tools/list, tools/call. Others get -32601 Method not found.
- An unknown tool is a JSON-RPC error (
-32602). - Bad arguments are a tool result with
isError: trueand the problems, so the model can fix them. - A tool that throws gives a tool result with
isError: trueand the error text.onToolErrorgets the error, for logs.
Protocol versions: 2025-11-25, 2025-06-18, 2025-03-26 and 2024-11-05. A client that asks for another one gets the newest.
Sessions
SignedMcpSessions (the default) makes ids like <random>.<signature>. The signature is HMAC-SHA256 of the random part and the scope, with a key made from APP_KEY (label dox:mcp).
- Every isolate, and every server with the same
APP_KEY, can check an id. Nothing is stored. - A new
APP_KEYends every session. Clients start a new one by themselves. - A signed id cannot be ended by itself. To refuse ended ids, give a cache store:
SignedMcpSessions(ended: Cache().store('redis')). Use a store that all isolates share (file or Redis, not memory).
For another kind of session, implement McpSessions.
OAuth sign-in
McpOAuth lets a client sign in by itself. You give the client only the address, like https://my.app/mcp. The client finds the rest. This is the sign-in the MCP spec asks for: OAuth 2.1 with PKCE.
dox_mcp does the OAuth part. Your app does three things:
- Tokens. Implement
McpOAuthTokens.issuemakes a token for a grant.verifygives what a token stands for (like a user), ornull. You choose the table and how a token is revoked. Keep only a hash of the token. - The consent page. Serve it at
<issuer>/oauth/authorize, or setauthorizePage. It readsclient_id,redirect_uri,state,code_challenge,code_challenge_methodandresourcefrom the query. The person signs in there the way your app always does. - The answer. Your page calls a route of your own, behind your own login. On yes, the route calls
oauth.approve(subject: ...)and gives the page the address to send the browser to. On no, it callsoauth.deny(...).oauth.checkClient(...)gives the client for the page and throws when the client or the redirect address is wrong. Then never send the browser anywhere. Put this route behind CSRF protection. Without it, another site can make a signed-in person say yes to a client without knowing it.
final McpOAuth oauth = McpOAuth(
issuer: 'https://my.app',
tokens: MyTokens(),
codes: Cache().store('file'), // all isolates must share it
);
class McpRouter extends Router {
@override
void register() {
oauth.routes(); // discovery, register and token
Route.match(<String>['GET', 'POST', 'DELETE'], '/mcp', handleMcp);
}
}
Future<DoxResponse> handleMcp(DoxRequest req) async {
Object? user = await oauth.authenticate(req); // reads Authorization: Bearer
if (user == null) return oauth.unauthorized(); // 401 with WWW-Authenticate
return mcp.handle(req, auth: user, scope: '$user');
}
On the consent page, show the redirect host, so the person sees where the browser goes. HTML-escape the client name before you print it. The client chose the name, so do not trust it.
Call oauth.routes() in a router with no prefix. The routes must sit at the root of the host. Clients look for /.well-known/... at the root, as the OAuth specs say. The discovery file also gives <issuer>/oauth/register and <issuer>/oauth/token as full addresses. Under a prefix, the routes move, but these addresses do not, so clients cannot find them.
oauth.routes() adds these routes:
| Route | What it does |
|---|---|
GET /.well-known/oauth-protected-resource (also with /mcp after it) |
Names the MCP address and the issuer. |
GET /.well-known/oauth-authorization-server |
Lists the endpoints. PKCE S256 only, authorization_code only, no client secret. |
POST /oauth/register |
Dynamic client registration. Nothing is stored. The client_id is the name and the redirect addresses, signed with APP_KEY. |
POST /oauth/token |
Swaps a code for a token. The body is a form or JSON. The code works once, for codeTtl (1 minute). The client, the redirect address and the PKCE verifier must all match. |
Rules:
- A redirect address must be
https, orhttponlocalhost,127.0.0.1or[::1]. It has no#fragment. It has at most 2048 characters (McpOAuth.maxRedirectUriLength). - A
code_verifiermust be 43 to 128 characters ofA-Z a-z 0-9 - . _ ~(RFC 7636). Other verifiers getinvalid_request. Acode_challengemust be 43 base64url characters. - The codes live in
codes, a Dox cache. Use a store that all isolates share (file on one host, Redis on many). Each code is taken under a lock, so two isolates cannot both swap it. - These routes answer any origin (CORS
*) and read no cookie. They skip the CORS list of the app. issuemay throwMcpOAuthException, likeaccess_deniedwhen a person has too many clients. The client gets it from the token endpoint.- There is no refresh token. To revoke a client, delete its token.
Security
- Check who calls before
handle: the MCP route has no auth of its own.McpOAuthis one way to do it. allowedOriginsstops browser pages on other sites (DNS rebinding). Set it when the server runs on a local or private network.- Keep tokens out of logs. A token in the URL ends up in proxy logs, so prefer a header when your clients can send one.