MCP server blueprint — how be-platform's MCP works, how to clone it¶
You are building an MCP server for another app running on meridian. Use
this document as the structural template. The be-platform reference
implementation lives at apps/be-platform/src/mcp/ in
github.com/jereanon/balanced-engineering.
The be-platform MCP server is a Rust + Rocket + SQLite app. If your target app uses a different stack (Python/Node/Go), the architecture still applies — only the file layout changes. The five components below are the same in any language.
Architecture at a glance¶
Client (Claude Desktop / Gilbert / curl)
│ Authorization: Bearer bep_mcp_<32 hex>
│ POST /mcp/v1 with JSON-RPC 2.0 body
▼
[transport layer] ← JSON or SSE framing per Accept header
│
▼
[McpUser auth guard] ← validates token, resolves user, populates roles
│
▼
[dispatch(method)] ← matches "initialize" | "tools/list" | "tools/call" | ...
│
▼
[tool execution] ← per-domain modules, each with tool_definitions() + execute()
│
▼
SQL query → JSON content envelope
The five components¶
1. Token table + model¶
One table stores all MCP API tokens. Plain token is never stored — only the SHA-256 hash. Cleartext is shown to the user once at creation and never again.
Schema (SQLite):
CREATE TABLE mcp_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL, -- user-supplied label, e.g. "Claude Desktop"
token_hash TEXT NOT NULL UNIQUE, -- sha256 of "<prefix>_<32-hex>"
last_used_at TEXT, -- updated on each request, fire-and-forget
revoked_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_mcp_tokens_user ON mcp_tokens(user_id, revoked_at);
CREATE INDEX idx_mcp_tokens_hash ON mcp_tokens(token_hash) WHERE revoked_at IS NULL;
Model methods (apps/be-platform/src/models/users/mcp_token.rs):
- generate(user_id, name) → returns (stored_row, cleartext). Caller must show cleartext once and never again.
- find_active_by_hash(hash) → used by the auth guard.
- list_for_user(user_id) → for the profile UI.
- revoke(id, user_id) → scoped to owner.
- touch_last_used(id) → idempotent, fire-and-forget after each request.
Token format: <app>_mcp_<32_lowercase_hex> (e.g. bep_mcp_a1b2...).
The prefix is your app's namespace — pick a 3-4 letter slug. The prefix
serves three purposes:
1. Tells the guard which app a token belongs to (rejects cross-app tokens fast)
2. Surface signal in logs ("[prefix: bep_mcp_a1b2c3d4]") for debugging
3. User-visible — makes it obvious what kind of credential it is
2. Auth guard¶
A request guard (Rocket-specific term; equivalents exist in every web framework — Express middleware, FastAPI dependency, etc.) that:
- Extracts
Authorization: Bearer <token>header (404/401 if missing) - Validates the prefix matches your app's namespace
- Hashes the cleartext and looks up the active token row
- Resolves the owning user; rejects if user is inactive
- Fire-and-forget updates
last_used_aton the token (NOT in the request critical path) - Returns the resolved user object to handlers
Reference: apps/be-platform/src/auth/guards.rs — search for McpUser.
Critical correctness rules:
- Always hash the incoming token before DB lookup. Never compare cleartext to cleartext (the DB only has the hash).
- Reject revoked tokens. The find_active_by_hash query filters WHERE revoked_at IS NULL.
- Reject inactive users. Even if the token is valid, an is_active = 0 user must not be granted access.
- Don't log the cleartext token. Log a short prefix (first 12 chars) for diagnostics. Logging the full token defeats hash-only storage.
3. JSON-RPC transport¶
Single endpoint: POST /mcp/v1. Accepts JSON-RPC 2.0 requests.
Reply format depends on the Accept header:
- Contains text/event-stream → SSE-framed (event: message\ndata: <json>\n\n)
- Otherwise → plain application/json
The MCP Python SDK (Claude Desktop) sends Accept: application/json,
text/event-stream and reads SSE better than plain JSON, so honor SSE
when offered. Plain JSON is the curl/dev path.
Dispatcher methods you must handle:
- initialize — handshake, returns protocolVersion ("2024-11-05"), capabilities ({"tools": {}}), serverInfo (name + version)
- notifications/initialized — no-op acknowledgement, return null result
- tools/list — returns {"tools": [...tool_definitions...]}
- tools/call — dispatches to the matching tool module's execute()
Anything else → JSON-RPC error -32601 method not found.
Reference: apps/be-platform/src/mcp/transport.rs and
apps/be-platform/src/mcp/mod.rs.
4. Tools¶
Each domain area gets its own module under mcp/tools/. Every tool
module exports two things:
(a) tool_definitions() -> Vec<Value> — returns the JSON Schema
for each tool the module exposes:
{
"name": "list_projects",
"description": "List projects with optional search and stage filter. Returns id, project_number, title, client_name, stage, pm_name.",
"inputSchema": {
"type": "object",
"properties": {
"search": {"type": "string", "description": "..."},
"stage": {"type": "string", "description": "..."},
"limit": {"type": "integer", "description": "Max results (default 50, max 100)"}
}
}
}
Description rules: - Start with a verb, say what the tool returns - List the fields the response includes — the LLM uses this to decide whether the tool answers the question - Mention defaults + limits (the LLM will respect them rather than retry blindly)
(b) execute(pool, user, params) -> Value — runs the tool, returns
an MCP content envelope:
Errors: same envelope with isError: true and a human-readable message
in the text field. Don't return JSON-RPC errors from tools — the JSON-RPC
layer is only for protocol-level failures (bad method, malformed
request). Tool errors are application-level and belong inside the
result envelope.
Reference patterns:
- mcp/tools/projects.rs — pure SQL read, no side effects
- mcp/tools/mark_complete.rs — write tool with permission checks
- mcp/tools/audit.rs — uses user parameter to filter by role
Register every tool in mcp/tools/mod.rs:
pub fn tool_definitions() -> Vec<Value> {
let mut tools = vec![];
tools.extend(projects::tool_definitions());
tools.extend(tests::tool_definitions());
// ... one line per module
tools
}
pub async fn execute(pool: &SqlitePool, user: &McpUser, params: &Value) -> Value {
let name = params.get("name").and_then(|v| v.as_str()).unwrap_or("");
match name {
"list_projects" | "get_project" => projects::execute(pool, user, params).await,
"list_tests" | "get_test" => tests::execute(pool, user, params).await,
// ... one match arm per module's tool set
_ => tool_error(&format!("Unknown tool: {name}")),
}
}
5. Token-issuance UI¶
Users mint their own tokens from the app's profile page. The flow:
- User clicks "Create MCP token", enters a name (e.g. "Claude Desktop")
- Server runs
McpToken::generate(user_id, name)→ returns(row, cleartext) - Shown once on a "Token created" confirmation page with copy-to-clipboard. Make this loud — heading + monospaced font + warning that this is the only time the token will be visible.
- Page also lists existing tokens by name + last_used_at + a Revoke button
- Revoke = soft delete (
revoked_at = datetime('now')), not a row delete
Reference handlers: apps/be-platform/src/portal/lab.rs — search for
lab_mcp_token_create and lab_mcp_token_revoke.
UX rules: - Token must be displayed in monospace + selectable + a copy button - Page must warn that the token grants the same permissions as the user - Revoking a token must NOT delete past audit log entries that reference it (use soft delete)
Mount point + routing¶
Mount the MCP routes at a fixed prefix under /mcp/v<N>. For
be-platform: /mcp/v1. The version number is separate from your
app's API version — it tracks the MCP protocol, not your API.
Wire-format contract example¶
Client sends:
POST /mcp/v1 HTTP/1.1
Authorization: Bearer bep_mcp_a1b2c3d4e5f6...
Accept: application/json
Content-Type: application/json
{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}
Server replies:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"tools": [
{"name": "list_projects", "description": "...", "inputSchema": {...}},
...
]
}
}
Tool call:
POST /mcp/v1 HTTP/1.1
Authorization: Bearer bep_mcp_a1b2c3d4e5f6...
Accept: text/event-stream
Content-Type: application/json
{"jsonrpc": "2.0", "id": 2, "method": "tools/call",
"params": {"name": "list_projects", "arguments": {"limit": 10}}}
Server replies (SSE-framed because client offered it):
event: message
data: {"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"[{...},{...}]"}],"isError":false}}
Step-by-step build order¶
When you're ready to build the new MCP, do it in this order. Each step is independently shippable.
- Migration + model + tests (1 PR)
- Add
mcp_tokenstable migration with a unique prefix slug for your app - Model with
generate/find_active_by_hash/list_for_user/revoke/touch_last_used - Unit tests for hash-on-write + revoked-token rejection
- Auth guard + token issuance UI (1 PR)
- Build the auth guard against the model from step 1
- Add the profile-page UI for creating + listing + revoking tokens
- "Created" page that shows cleartext ONCE
- Transport + dispatch (1 PR)
- Single endpoint at
/mcp/v1 - JSON-RPC envelope + Accept-header content negotiation
- Implement
initialize,notifications/initialized,tools/list(empty),tools/call(returns method-not-found until tools land) - First tool (1 PR)
- Pick one trivial read-only tool from your app's domain
- Wire it through
tools/mod.rsregistration - Test end-to-end with
curl(no Claude Desktop yet) - Remaining tools (per-domain PRs)
- Add one domain module per PR — easier to review, easier to roll back
After step 3 you can already connect Claude Desktop and see your server
respond to tools/list. After step 4 it's actually useful.
Testing the running server¶
Smoke test with curl:
# Mint a token via the profile UI first, then:
TOKEN="bep_mcp_..."
BASE="https://your-app.example.com/mcp/v1"
# Initialize
curl -X POST $BASE \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'
# List tools
curl -X POST $BASE \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
# Call a tool
curl -X POST $BASE \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"<tool_name>","arguments":{}}}'
For Claude Desktop integration, add a stanza to claude_desktop_config.json:
{
"mcpServers": {
"<your-app>": {
"url": "https://your-app.example.com/mcp/v1",
"transport": "http",
"headers": {
"Authorization": "Bearer bep_mcp_..."
}
}
}
}
Things to copy verbatim from be-platform¶
secure_token::generate_raw_token()(cryptographically-strong 32-hex random) +hash_token()(sha256) helpers — same code works for any app- The JSON-RPC envelope structs + the error code constants (
-32601method not found,-32602invalid params, etc.) - The "shown once" token-created page template — UX patterns transfer regardless of stack
Things you'll need to design fresh¶
- The tool surface area. This is your app's domain. Don't copy be-platform's tools — they query material tests, projects, equipment. Yours will query whatever your app stores.
- Permission model. be-platform uses role tags (
admin,project_manager,field_engineer). Your app has its own roles. Decide which tools each role can call. - Audit log integration. be-platform writes audit-log rows for every write tool. Decide whether your app needs the same and which tools count as "interesting" for audit.
Reference paths in this repo¶
apps/be-platform/migrations/126_mcp_tokens.sql ← schema
apps/be-platform/src/models/users/mcp_token.rs ← token model
apps/be-platform/src/models/secure_token.rs ← generate/hash helpers
apps/be-platform/src/auth/guards.rs ← McpUser guard (~ line 236)
apps/be-platform/src/mcp/mod.rs ← route mount + content negotiation
apps/be-platform/src/mcp/transport.rs ← JSON-RPC dispatch
apps/be-platform/src/mcp/error.rs ← JsonRpcError codes
apps/be-platform/src/mcp/tools/mod.rs ← tool registration
apps/be-platform/src/mcp/tools/projects.rs ← clean read-tool example
apps/be-platform/src/mcp/tools/mark_complete.rs ← write-tool example with permission checks
apps/be-platform/src/portal/lab.rs ← token-issuance UI handlers (~ line 1270)
apps/be-platform/src/main.rs ← .mount("/mcp/v1", mcp::routes())
One-paragraph elevator pitch (for your prompt context)¶
Build an MCP server that lets Claude Desktop query and mutate the app's data using JSON-RPC 2.0 over HTTP. Authentication is a bearer token issued from the user's profile page; only the SHA-256 hash is stored, the cleartext is shown to the user exactly once at creation time. The token uses an app-specific prefix (e.g.
bep_mcp_) for namespace clarity. Tools live undersrc/mcp/tools/<domain>.rsand each module exportstool_definitions()(JSON Schema descriptions) andexecute()(the actual handler). Mount everything at/mcp/v1. SeeMCP_BLUEPRINT.mdfor the structural template — copy the auth + transport + token-issuance UI verbatim, design the tool surface fresh for the new app's domain.