Skip to content

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:

  1. Extracts Authorization: Bearer <token> header (404/401 if missing)
  2. Validates the prefix matches your app's namespace
  3. Hashes the cleartext and looks up the active token row
  4. Resolves the owning user; rejects if user is inactive
  5. Fire-and-forget updates last_used_at on the token (NOT in the request critical path)
  6. 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:

{
  "content": [{"type": "text", "text": "<json-stringified result>"}],
  "isError": false
}

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:

  1. User clicks "Create MCP token", enters a name (e.g. "Claude Desktop")
  2. Server runs McpToken::generate(user_id, name) → returns (row, cleartext)
  3. 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.
  4. Page also lists existing tokens by name + last_used_at + a Revoke button
  5. 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.

.mount("/mcp/v1", mcp::routes())

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.

  1. Migration + model + tests (1 PR)
  2. Add mcp_tokens table migration with a unique prefix slug for your app
  3. Model with generate / find_active_by_hash / list_for_user / revoke / touch_last_used
  4. Unit tests for hash-on-write + revoked-token rejection
  5. Auth guard + token issuance UI (1 PR)
  6. Build the auth guard against the model from step 1
  7. Add the profile-page UI for creating + listing + revoking tokens
  8. "Created" page that shows cleartext ONCE
  9. Transport + dispatch (1 PR)
  10. Single endpoint at /mcp/v1
  11. JSON-RPC envelope + Accept-header content negotiation
  12. Implement initialize, notifications/initialized, tools/list (empty), tools/call (returns method-not-found until tools land)
  13. First tool (1 PR)
  14. Pick one trivial read-only tool from your app's domain
  15. Wire it through tools/mod.rs registration
  16. Test end-to-end with curl (no Claude Desktop yet)
  17. Remaining tools (per-domain PRs)
  18. 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 (-32601 method not found, -32602 invalid 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 under src/mcp/tools/<domain>.rs and each module exports tool_definitions() (JSON Schema descriptions) and execute() (the actual handler). Mount everything at /mcp/v1. See MCP_BLUEPRINT.md for the structural template — copy the auth + transport + token-issuance UI verbatim, design the tool surface fresh for the new app's domain.