dox_mcp 3.1.1
dox_mcp: ^3.1.1 copied to clipboard
Model Context Protocol (MCP) server for the Dox framework: tools over Streamable HTTP, with signed sessions that work on every isolate.
dox_mcp #
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 #
dart pub add dox_mcp
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);
},
);
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 or ended session id, or one of another scope |
404, so the client starts a new session |
POST with an unsupported MCP-Protocol-Version header |
400 |
POST with another Content-Type than JSON |
415 |
GET |
405: there is no server-to-client stream |
DELETE with a session id |
200, the session ends |
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 can not 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. This is the sign-in of the MCP spec (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 the user id), or null. You choose the table and how a token is revoked. Keep only a hash of it. - The consent page. At
<issuer>/oauth/authorize(orauthorizePage:). 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 yes. Your page calls your own route, with your own login. That route calls
oauth.approve(subject: ...)and gives the page the address to send the browser to. On no,oauth.deny(...).oauth.checkClient(...)gives the client's name for the page.
final McpOAuth oauth = McpOAuth(
issuer: 'https://my.app',
tokens: MyTokens(),
codes: Cache().store('file'), // shared by the isolates
);
class McpRouter extends Router {
@override
void register() {
oauth.routes(); // discovery, register, token
Route.match(<String>['GET', 'POST', 'DELETE'], '/mcp', handleMcp);
}
}
Future<DoxResponse> handleMcp(DoxRequest req) async {
Object? user = await oauth.authenticate(req); // Authorization: Bearer
if (user == null) return oauth.unauthorized(); // 401 + WWW-Authenticate
return mcp.handle(req, auth: user, scope: '$user');
}
| Route | What it does |
|---|---|
GET /.well-known/oauth-protected-resource (also with /mcp after it) |
Names the MCP address and the issuer (RFC 9728). |
GET /.well-known/oauth-authorization-server |
The endpoints (RFC 8414). PKCE S256 only, authorization_code only, no client secret. |
POST /oauth/register |
Dynamic client registration (RFC 7591). Nothing is stored: the client_id is the name and redirect addresses, signed with APP_KEY (label dox:mcp-oauth). |
POST /oauth/token |
Swaps a code for a token. Form or JSON body. The code works once and for codeTtl (1 minute). The client, the redirect address and the PKCE verifier must match. Answers no-store. |
Rules:
- A redirect address must be
https, orhttponlocalhost,127.0.0.1or[::1]. No#fragment. - 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 can not both swap it. - The public routes answer any origin (CORS
*) and read no cookie. They skip the app's CORS list. issuemay throwMcpOAuthException(likeaccess_deniedfor too many clients). The client gets it from the token endpoint.- There is no refresh token. The app revokes a client by deleting its token.
Security #
- Check who calls before
handle: the MCP route has no auth of its own.McpOAuthis one way. 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.