16 KiB
Model Context Protocol (MCP) in Dashback
This document explains the architecture, database schema, OAuth 2.1 authentication flow, and endpoint implementation of the Model Context Protocol (MCP) server in Dashback.
1. Overview & Purpose
The Dashback backend provides an MCP server running over Streamable HTTP at /mcp. It exposes internal tools (dashback_request, dash-subagent, and search-articles) to external MCP clients such as:
- Visual Studio Code / GitHub Copilot
- Claude Desktop
- Cursor / Windsurf
All operations executed via these tools are cryptographically authenticated and scoped to the authorizing Dashback user (google_auth_id) and their selected company.
2. Authentication: OAuth 2.1 Specification Compliance
MCP remote HTTP transports require standardized authorization following OAuth 2.1. Dashback implements a full, built-in OAuth 2.1 authorization server supporting:
| Standard | Purpose | Endpoint(s) |
|---|---|---|
| RFC 9728 | Protected Resource Metadata (PRM) | /.well-known/oauth-protected-resource/.well-known/oauth-protected-resource/mcp |
| RFC 8414 / OIDC | Authorization Server Metadata | /.well-known/oauth-authorization-server/.well-known/openid-configuration |
| RFC 7591 | Dynamic Client Registration (DCR) | POST /oauth/register |
| RFC 6749 & RFC 7636 | Authorization Code Flow with PKCE (S256) |
GET /oauth/authorizePOST /oauth/authorize |
| RFC 6749 | Token Exchange & Refresh | POST /oauth/token |
| RFC 7662 | Token Introspection | POST /oauth/introspect |
| RFC 8252 §7.3 | Native App Loopback Interface Redirection | Port-agnostic redirect URI matching for 127.0.0.1 and localhost |
3. Database Schema (dashdbschema)
Three new tables were added to both MSSQL (src/providers/mssql/schema.js) and SQLite (src/providers/sqlite3/schema.js):
3.1. mcp_client
Stores dynamically registered MCP clients (e.g., VS Code instances).
id(INTEGER / INT IDENTITY, PK)client_id(NVARCHAR(100) / TEXT, UNIQUE NOT NULL)client_secret(NVARCHAR(255) / TEXT, NULL)client_name(NVARCHAR(255) / TEXT, NOT NULL)redirect_uris(NVARCHAR(MAX) / TEXT, NOT NULL, JSON array or URI list)grant_types(NVARCHAR(255) / TEXT, NULL, e.g.'authorization_code,refresh_token')response_types(NVARCHAR(255) / TEXT, NULL, e.g.'code')scope(NVARCHAR(255) / TEXT, NULL, default'mcp:tools')created_at(DATETIME2 / TEXT)
3.2. mcp_auth_code
Stores short-lived authorization codes (5-minute TTL) during the PKCE handshake.
code(NVARCHAR(128) / TEXT, PK)client_id(NVARCHAR(100) / TEXT, NOT NULL)google_auth_id(INT / INTEGER, FKgoogle_auth(id) ON DELETE CASCADE)redirect_uri(NVARCHAR(2048) / TEXT, NOT NULL)code_challenge(NVARCHAR(255) / TEXT, NOT NULL)code_challenge_method(NVARCHAR(32) / TEXT, NOT NULL, e.g.'S256')scope(NVARCHAR(255) / TEXT, NULL)expires_at(DATETIME2 / TEXT, NOT NULL)used(TINYINT / INTEGER, NOT NULL DEFAULT 0)created_at(DATETIME2 / TEXT)
3.3. mcp_token
Stores refresh tokens (30-day TTL) for long-lived MCP connections.
id(INTEGER / INT IDENTITY, PK)refresh_token(NVARCHAR(128) / TEXT, UNIQUE NOT NULL)client_id(NVARCHAR(100) / TEXT, NOT NULL)google_auth_id(INT / INTEGER, FKgoogle_auth(id) ON DELETE CASCADE)scope(NVARCHAR(255) / TEXT, NULL)expires_at(DATETIME2 / TEXT, NOT NULL)revoked(TINYINT / INTEGER, NOT NULL DEFAULT 0)created_at(DATETIME2 / TEXT)
Store Methods (libdashstore)
Under src/store/mcp/ in dashdbschema:
store.mcp.registerClient(input)store.mcp.getClient(clientId)store.mcp.listClients()store.mcp.deleteClient(clientId)store.mcp.createAuthCode(input)store.mcp.getAuthCode(code)store.mcp.consumeAuthCode(code)store.mcp.createRefreshToken(input)store.mcp.getRefreshToken(refreshToken)store.mcp.revokeRefreshToken(refreshToken)
Wrapped with execution timing logs in dashback via src/db/mcp.js and STORE_NAMESPACES.
4. Endpoints & Flow Breakdown
All routes are defined in src/api/routes/mcp.js.
Step 1: Protected Resource Discovery
When an MCP client connects to POST /mcp without credentials:
- Server responds with
401 Unauthorizedand header:WWW-Authenticate: Bearer error="invalid_token", error_description="Missing Authorization header", resource_metadata="http://<host>:4208/.well-known/oauth-protected-resource" - The client queries
GET /.well-known/oauth-protected-resource, receiving:{ "resource": "http://<host>:4208/mcp", "authorization_servers": ["http://<host>:4208"], "scopes_supported": ["mcp:tools"], "resource_name": "Dashback MCP Server" }
Step 2: Authorization Server Discovery
The client queries GET /.well-known/oauth-authorization-server, receiving:
{
"issuer": "http://<host>:4208",
"authorization_endpoint": "http://<host>:4208/oauth/authorize",
"token_endpoint": "http://<host>:4208/oauth/token",
"registration_endpoint": "http://<host>:4208/oauth/register",
"introspection_endpoint": "http://<host>:4208/oauth/introspect",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"],
"scopes_supported": ["mcp:tools"]
}
Step 3: Dynamic Client Registration
The client registers with POST /oauth/register:
{
"client_name": "Visual Studio Code",
"redirect_uris": [
"https://vscode.dev/redirect",
"http://127.0.0.1/",
"http://127.0.0.1:33418/"
],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"]
}
The server stores the client in mcp_client and returns status 201 Created with a unique client_id (UUID).
Step 4: User Authorization & Consent via Frontend
When the MCP client directs the user's browser to authenticate:
- The MCP client reads
authorization_endpointfrom/.well-known/oauth-authorization-server, which resolves tohttp://<host>:4209/oauth/authorize. - If an agent or client hits
GET /oauth/authorizedirectly on the backend, the backend redirects (HTTP 302) to${frontendUrl}/oauth/authorize?<queryParams>. - The user is authenticated in the Dashfront frontend SPA.
- The frontend calls
GET /api/mcp/authorize?<queryParams>to fetch client details and user context for the consent dialog. - When the user clicks Authorize, the frontend calls
POST /api/mcp/authorize(action: "allow"). - The server generates a single-use authorization code (in
mcp_auth_code) and returns{ ok: true, redirectUrl: "http://127.0.0.1:<port>/?code=<code>&state=<state>" }. - The frontend sets
window.location.href = data.redirectUrl, delivering the code to the client loopback listener. - Loopback Port Matching (RFC 8252 §7.3): Native applications bind to ephemeral ports (e.g.
54754). The server usesisRedirectUriAllowed()to permit any port on127.0.0.1andlocalhostwhen the registered URI is on loopback.
Step 5: Token Exchange
The MCP client sends a POST request with PKCE verifier to POST /oauth/token:
POST /oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&code=<code>&client_id=<id>&redirect_uri=...&code_verifier=<verifier>
The server:
- Validates
code, client ID, expiration, and unused status. - Computes
BASE64URL(SHA256(code_verifier))and compares it againstcode_challenge. - Marks the code as used in
mcp_auth_code. - Issues a signed JWT access token (1-hour TTL) with payload
{ sub: userId, id: userId, client_id, scope: "mcp:tools", aud: "<host>/mcp" }. - Issues a 30-day random hex refresh token stored in
mcp_token.
Step 6: MCP Session Handling (/mcp)
The client connects to /mcp with Authorization: Bearer <access_token>.
requireBearerAuth: Validates the JWT and verifies that theuserIdexists and is active ingoogle_auth.StreamableHTTPServerTransport(@modelcontextprotocol/sdk):POST /mcp: Handles sessioninitialize(generating a session UUID and returningMcp-Session-Idheader) and subsequent JSON-RPC tool calls.GET /mcp: Handles server-sent events (SSE) streaming connections.DELETE /mcp: Closes the transport session.
5. The Exposed Tools
The MCP server exposes three tools to external clients:
1. dashback_request
Performs HTTP requests against the Dashback API:
server.registerTool('dashback_request', {
title: 'Dashback API Request',
description: 'Perform HTTP requests against the Dashback API',
inputSchema: {
method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']),
path: z.string().describe('e.g. /articles, /orders, /category'),
query: z.record(z.string(), z.any()).nullable().optional(),
body: z.union([z.string(), z.record(z.string(), z.any())]).nullable().optional(),
note: z.string().nullable().optional(),
},
}, async ({ method = 'GET', path, query = null, body = null, note }) => { ... });
2. dash-subagent
Delegates a self-contained task to an isolated subagent with access to Dashback tools and VFS:
server.registerTool('dash-subagent', {
title: 'Dashback Subagent',
description: 'Delegate a task to an isolated subagent with access to Dashback tools and VFS.',
inputSchema: {
task: z.string().describe('Specific instructions and goal for the subagent.'),
skills: z.array(z.string()).nullable().optional().describe('Array of SKILL names or slugs to inject.'),
conversation_id: z.number().int().positive().nullable().optional().describe('Existing subagent conversation ID to follow up on, or null to create a new one.'),
max_iterations: z.number().int().min(1).max(30).nullable().optional().describe('Safety bound on tool loops (default 15, max 30).'),
model: z.string().nullable().optional().describe('Optional OpenRouter model override.'),
note: z.string().nullable().optional(),
},
}, async ({ task, skills, conversation_id, max_iterations, model, note }, extra) => { ... });
3. search-articles
Searches articles across name, brand, EAN, SKU, and supplier SKU (AND logic, ordered by name):
server.registerTool('search-articles', {
title: 'Search Articles',
description: 'Search articles by name, brand, EAN, SKU, or supplier SKU (AND logic, ordered by name).',
inputSchema: {
searchquery: z.string().nullable().optional().describe('Keywords matching name, brand, EAN, SKU, or supplier SKU (AND logic).'),
excludewords: z.string().nullable().optional().describe('Keywords to exclude (AND logic).'),
offset: z.number().int().min(0).nullable().optional().describe('Pagination offset (default 0).'),
limit: z.number().int().min(1).max(20).nullable().optional().describe('Window size 1–20 (default 3).'),
},
}, async ({ searchquery, excludewords, offset, limit }) => { ... });
Because all tool executions use credentials scoped to the authenticated user's google_auth_id, all Dashback route-level permissions, company isolation (req.user.selectedCompanyId), audit logging, and data scoping apply automatically.
6. How to Connect from VS Code / GitHub Copilot
Configuration
Add the server to your VS Code MCP configuration (either in .vscode/mcp.json or your global VS Code settings):
{
"dashback": {
"url": "http://127.0.0.1:4208/mcp",
"type": "http"
}
}
(If connecting from another device over LAN, use http://<server-ip>:4208/mcp).
First Connection Flow:
- Open VS Code with Copilot Chat.
- VS Code contacts
/mcp, triggers OAuth 2.1 discovery, and asks you to open a browser for authorization. - Click Open and press Authorize on the Dashback consent screen.
- VS Code receives the tokens and displays
#dashback_requestin Copilot Chat tools.
7. Frontend Developer Implementation Guide
The frontend (dashfront, port 4209) needs two views / features:
- Consent Screen Route (
/oauth/authorize): Where external MCP clients send users to approve access. - Authorizations Management (Settings/Security): Where users view authorized MCP clients and revoke access.
7.1. Consent Screen Route: /oauth/authorize
When an MCP client initiates authentication, the user is navigated to:
http://<host>:4209/oauth/authorize?client_id=...&redirect_uri=...&response_type=code&code_challenge=...&code_challenge_method=S256&state=...&scope=mcp:tools
Step 1: Ensure User Authentication
- Check if the user is authenticated in Dashfront.
- If not logged in, redirect to
/loginpreserving the full URL asreturnToso they return here after Google SSO.
Step 2: Fetch Authorization Prompt Metadata
The frontend makes an authenticated GET request:
GET /api/mcp/authorize?<all_query_params_from_url>
Response (200 OK):
{
"client": {
"clientId": "642a188e-036f-4f5a-8448-95207096c4ef",
"clientName": "Visual Studio Code",
"scope": "mcp:tools"
},
"user": {
"id": 1,
"email": "user@example.com"
},
"params": {
"clientId": "642a188e-036f-4f5a-8448-95207096c4ef",
"redirectUri": "http://127.0.0.1:54754/",
"codeChallenge": "...",
"codeChallengeMethod": "S256",
"state": "...",
"scope": "mcp:tools"
}
}
Step 3: Render Consent UI
Display a clean dialog or card:
- Application requesting access:
client.clientName - Authorizing account:
user.email - Permissions requested:
dashback_requestAPI tools (mcp:tools) - Action buttons:
- Authorize (Allow)
- Cancel (Deny)
Step 4: Submit Consent Decision
When the user clicks Authorize:
POST /api/mcp/authorize
Content-Type: application/json
{
"client_id": "<params.clientId>",
"redirect_uri": "<params.redirectUri>",
"code_challenge": "<params.codeChallenge>",
"code_challenge_method": "<params.codeChallengeMethod>",
"state": "<params.state>",
"scope": "<params.scope>",
"action": "allow"
}
Response (200 OK):
{
"ok": true,
"redirectUrl": "http://127.0.0.1:54754/?code=<auth_code>&state=<state>"
}
The frontend immediately navigates the browser:
window.location.href = res.data.redirectUrl;
When the user clicks Cancel:
POST /api/mcp/authorize
Content-Type: application/json
{
"client_id": "<params.clientId>",
"redirect_uri": "<params.redirectUri>",
"state": "<params.state>",
"action": "deny"
}
Response (200 OK):
{
"ok": false,
"redirectUrl": "http://127.0.0.1:54754/?error=access_denied&state=<state>"
}
Navigate: window.location.href = res.data.redirectUrl;.
7.2. Authorizations Management (Settings / Security)
In the user profile or security settings, users can view active MCP client authorizations and revoke them.
List Active Authorizations
GET /api/mcp/authorizations
Response (200 OK):
{
"authorizations": [
{
"clientDbId": 3,
"clientId": "0a2e6bbb-05a5-4b03-bed1-9b853b5aec34",
"clientName": "Visual Studio Code",
"scope": "mcp:tools",
"clientCreatedAt": "2026-10-12T20:36:46.036Z",
"authorizedAt": "2026-10-12T20:39:28.148Z",
"tokenCount": 1
}
]
}
Revoke an Authorization
When the user clicks Revoke / Delete:
DELETE /api/mcp/authorizations/:clientId
Response (200 OK):
{
"ok": true,
"message": "Authorizations revoked"
}
This immediately revokes all active refresh tokens for that client and the user. Any future token refresh requests by that client will be rejected with invalid_grant.