Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions packages/mcp-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,21 @@ transport route (`POST /mcp`) that speaks JSON-RPC 2.0 and lists an internal smo
structured logging with request/correlation ids, the OAuth protected-resource metadata endpoint,
Auth0 bearer-token verification, identity mapping from a verified token to an internal user +
business-membership context, a curated tool registry with strict input validation, a per-tool
authorization policy evaluator, and a hardened upstream GraphQL client. Production tools that use
these building blocks are not implemented yet.
authorization policy evaluator, and a hardened upstream GraphQL client. The first production tool
(read-only charges search) is wired into `tools/list` / `tools/call` and enforces input validation,
authorization policy, and business-scope narrowing before execution.

## Tools

`tools/call` for a registered tool runs input validation → authorization policy → handler, and maps
failures to a tool result with `isError` and a `{ code, message, correlationId, retryable? }`
payload (spec §10.2): `VALIDATION_ERROR`, `AUTHORIZATION_ERROR`, `UPSTREAM_ERROR`, `TIMEOUT_ERROR`,
`INTERNAL_ERROR`.

- **`accounter_search_charges`** — read-only charges search/browse within the caller's authorized
businesses. Optional `businessIds` (subset of memberships), `fromDate`/`toDate` (bounded to 366
days), `tags`, `freeText`, and `flow` (`ALL`/`INCOME`/`EXPENSE`), with bounded pagination
(`pageSize` ≤ 50). Returns normalized charges plus pagination metadata.

## Upstream GraphQL client

Expand Down
36 changes: 35 additions & 1 deletion packages/mcp-server/src/mcp/__tests__/handler.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type { IncomingMessage, ServerResponse } from 'node:http';
import { Readable } from 'node:stream';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildAuthContext } from '../../auth/identity.js';
import { TokenVerificationError } from '../../auth/token.js';
import { verifyAccessToken } from '../../auth/verifier.js';
import { handleMcpBody, MCP_PROTOCOL_VERSION, mcpHttpHandler } from '../handler.js';
import { dispatchMcpRequest, handleMcpBody, MCP_PROTOCOL_VERSION, mcpHttpHandler } from '../handler.js';
import type { JsonRpcErrorResponse, JsonRpcSuccess } from '../jsonrpc.js';
import { JsonRpcErrorCode } from '../jsonrpc.js';
import { SMOKE_TOOL_NAME } from '../tools.js';
Expand Down Expand Up @@ -204,6 +205,39 @@ describe('mcpHttpHandler', () => {
});
});

describe('dispatchMcpRequest — registry integration', () => {
const auth = buildAuthContext(
{
subject: 'user-1',
issuer: 'https://tenant.auth0.com/',
audience: 'aud',
scopes: [],
email: null,
expiresAt: undefined,
claims: { sub: 'user-1' },
},
[],
);

it('lists the smoke tool alongside the registered production tools', async () => {
const response = (await dispatchMcpRequest(
{ jsonrpc: '2.0', id: 1, method: 'tools/list' },
{ auth, correlationId: 'c' },
)) as JsonRpcSuccess;
const names = (response.result as { tools: Array<{ name: string }> }).tools.map(t => t.name);
expect(names).toContain(SMOKE_TOOL_NAME);
expect(names).toContain('accounter_search_charges');
});

it('returns InvalidParams for an unknown tool name', async () => {
const response = (await dispatchMcpRequest(
{ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'nope' } },
{ auth, correlationId: 'c' },
)) as JsonRpcErrorResponse;
expect(response.error.code).toBe(JsonRpcErrorCode.InvalidParams);
});
});

describe('hasBearerToken', () => {
it('detects a bearer token (case-insensitive)', async () => {
const { hasBearerToken } = await import('../handler.js');
Expand Down
117 changes: 106 additions & 11 deletions packages/mcp-server/src/mcp/handler.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import type { IncomingMessage, ServerResponse } from 'node:http';
import { resolveAuthContext, setAuthContext } from '../auth/identity.js';
import {
getAuthContext,
resolveAuthContext,
setAuthContext,
type McpAuthContext,
} from '../auth/identity.js';
import {
extractBearerToken,
setAuthPrincipal,
Expand All @@ -12,6 +17,9 @@ import { getRequestContext } from '../context.js';
import { createRequestLogger, log } from '../logger.js';
import { sendUnauthorized } from '../oauth/challenge.js';
import { protectedResourceMetadataUrl } from '../oauth/metadata.js';
import { executeRegisteredTool } from '../tools/execute.js';
import { toolRegistry } from '../tools/registry-instance.js';
import { getUpstreamClient } from '../upstream/default-client.js';
import { getServiceVersion, SERVICE_NAME } from '../version.js';
import {
asJsonRpcRequest,
Expand Down Expand Up @@ -88,32 +96,106 @@ export function handleRpcRequest(request: JsonRpcRequest): JsonRpcResponse | nul
}
}

/**
* Process a raw (already string-decoded) request body into a JSON-RPC response.
* Returns `null` for notifications. Never throws for malformed input — it maps
* to the appropriate JSON-RPC error instead.
*/
export function handleMcpBody(raw: string): JsonRpcResponse | null {
/** Parse a raw body into a JSON-RPC request or a terminal error response. */
function parseMcpBody(raw: string): { request: JsonRpcRequest } | { response: JsonRpcResponse } {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return failure(null, JsonRpcErrorCode.ParseError, 'Parse error: body is not valid JSON');
return {
response: failure(null, JsonRpcErrorCode.ParseError, 'Parse error: body is not valid JSON'),
};
}

// JSON-RPC batching is not supported by MCP 2025-06-18.
if (Array.isArray(parsed)) {
return failure(null, JsonRpcErrorCode.InvalidRequest, 'Batch requests are not supported');
return {
response: failure(null, JsonRpcErrorCode.InvalidRequest, 'Batch requests are not supported'),
};
}

const request = asJsonRpcRequest(parsed);
if (!request) {
return failure(null, JsonRpcErrorCode.InvalidRequest, 'Invalid JSON-RPC 2.0 request');
return {
response: failure(null, JsonRpcErrorCode.InvalidRequest, 'Invalid JSON-RPC 2.0 request'),
};
}
return { request };
}

/**
* Process a raw (already string-decoded) request body into a JSON-RPC response.
* Returns `null` for notifications. Never throws for malformed input — it maps
* to the appropriate JSON-RPC error instead. Synchronous path: does not execute
* registry tools (see {@link dispatchMcpBody}).
*/
export function handleMcpBody(raw: string): JsonRpcResponse | null {
const parsed = parseMcpBody(raw);
return 'response' in parsed ? parsed.response : handleRpcRequest(parsed.request);
}

/** Per-request context for the authenticated tool-dispatch path. */
export interface McpDispatchContext {
auth: McpAuthContext;
correlationId: string;
/** Caller's Authorization header value, forwarded upstream (never logged). */
authorization?: string;
}

/**
* Async dispatch used by the HTTP handler. Handles `tools/list` (curated
* registry + the smoke tool) and `tools/call` for registered tools (validation
* → policy → execution), delegating everything else to {@link handleRpcRequest}.
*/
export async function dispatchMcpRequest(
request: JsonRpcRequest,
context: McpDispatchContext,
): Promise<JsonRpcResponse | null> {
if (isNotification(request)) {
return null;
}
const id = request.id ?? null;

if (request.method === 'tools/list') {
return success(id, { tools: [...listedTools, ...toolRegistry.describe()] });
}

if (request.method === 'tools/call') {
const params = (request.params ?? {}) as { name?: unknown; arguments?: unknown };
const name = typeof params.name === 'string' ? params.name : '';
if (name === SMOKE_TOOL_NAME) {
return success(id, runSmokeTool(params.arguments));
}
const tool = toolRegistry.get(name);
if (!tool) {
return failure(id, JsonRpcErrorCode.InvalidParams, `Unknown tool: ${name}`);
}
const result = await executeRegisteredTool({
tool,
rawArgs: params.arguments,
auth: context.auth,
correlationId: context.correlationId,
authorization: context.authorization,
client: getUpstreamClient(),
});
return success(id, result);
}

return handleRpcRequest(request);
}

/** Parse + async-dispatch a raw body. Returns `null` for notifications. */
export async function dispatchMcpBody(
raw: string,
context: McpDispatchContext,
): Promise<JsonRpcResponse | null> {
const parsed = parseMcpBody(raw);
if ('response' in parsed) {
return parsed.response;
}
return dispatchMcpRequest(parsed.request, context);
}

function readBody(req: IncomingMessage, maxBytes: number): Promise<string> {
return new Promise((resolve, reject) => {
let size = 0;
Expand Down Expand Up @@ -227,7 +309,20 @@ export async function mcpHttpHandler(req: IncomingMessage, res: ServerResponse):
return;
}

const response = handleMcpBody(raw);
const auth = getAuthContext(req);
if (!auth) {
// Should be set by authenticate(); treat an unexpected miss as internal.
log('error', 'authenticated request is missing its auth context');
sendJson(res, 500, failure(null, JsonRpcErrorCode.InternalError, 'Internal server error'));
return;
}

const response = await dispatchMcpBody(raw, {
auth,
correlationId: getRequestContext(req)?.correlationId ?? '',
authorization:
typeof req.headers.authorization === 'string' ? req.headers.authorization : undefined,
});

if (response === null) {
// Notification: acknowledge without a JSON-RPC response body.
Expand Down
Loading
Loading