diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 0f71b3483..47f92c043 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -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 diff --git a/packages/mcp-server/src/mcp/__tests__/handler.test.ts b/packages/mcp-server/src/mcp/__tests__/handler.test.ts index aea4b29e6..3b611c11c 100644 --- a/packages/mcp-server/src/mcp/__tests__/handler.test.ts +++ b/packages/mcp-server/src/mcp/__tests__/handler.test.ts @@ -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'; @@ -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'); diff --git a/packages/mcp-server/src/mcp/handler.ts b/packages/mcp-server/src/mcp/handler.ts index af6858b27..e1a9f8a6c 100644 --- a/packages/mcp-server/src/mcp/handler.ts +++ b/packages/mcp-server/src/mcp/handler.ts @@ -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, @@ -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, @@ -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 { + 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 { + const parsed = parseMcpBody(raw); + if ('response' in parsed) { + return parsed.response; + } + return dispatchMcpRequest(parsed.request, context); +} + function readBody(req: IncomingMessage, maxBytes: number): Promise { return new Promise((resolve, reject) => { let size = 0; @@ -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. diff --git a/packages/mcp-server/src/tools/__tests__/charges.test.ts b/packages/mcp-server/src/tools/__tests__/charges.test.ts new file mode 100644 index 000000000..44b31fc2f --- /dev/null +++ b/packages/mcp-server/src/tools/__tests__/charges.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it, vi } from 'vitest'; +import { buildAuthContext, type McpAuthContext } from '../../auth/identity.js'; +import type { AuthPrincipal } from '../../auth/token.js'; +import { UpstreamGraphQLClient } from '../../upstream/graphql-client.js'; +import { searchChargesTool } from '../charges.js'; +import { executeRegisteredTool } from '../execute.js'; + +function authContext(businessIds: string[]): McpAuthContext { + const principal: AuthPrincipal = { + subject: 'user-1', + issuer: 'https://tenant.auth0.com/', + audience: 'aud', + scopes: [], + email: null, + expiresAt: undefined, + claims: { sub: 'user-1' }, + }; + return buildAuthContext( + principal, + businessIds.map(businessId => ({ businessId, roleId: 'accountant' })), + ); +} + +function clientReturning(data: unknown, capture?: (body: unknown) => void) { + const fetchImpl = vi.fn(async (_url: string, init: RequestInit) => { + capture?.(JSON.parse(init.body as string)); + return { ok: true, status: 200, json: async () => ({ data }) } as unknown as Response; + }); + return new UpstreamGraphQLClient({ + endpoint: 'http://localhost:4000/graphql', + timeoutMs: 1000, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); +} + +function run(client: UpstreamGraphQLClient, auth: McpAuthContext, rawArgs: unknown) { + return executeRegisteredTool({ + tool: searchChargesTool, + rawArgs, + auth, + correlationId: 'corr-1', + client, + authorization: 'Bearer tok', + }); +} + +const oneCharge = { + allCharges: { + nodes: [ + { + id: 'c1', + userDescription: 'Coffee', + totalAmount: { raw: 12.5, formatted: '₪12.50', currency: 'ILS' }, + minEventDate: '2026-01-05', + }, + ], + pageInfo: { totalPages: 1, totalRecords: 1, currentPage: 1, pageSize: 25 }, + }, +}; + +describe('searchChargesTool — successful read', () => { + it('normalizes charges and pagination', async () => { + const client = clientReturning(oneCharge); + const result = await run(client, authContext(['b1']), { fromDate: '2026-01-01' }); + + expect(result.isError).toBeUndefined(); + const structured = result.structuredContent as { + charges: Array<{ id: string; amount: { value: number } | null }>; + pagination: { totalRecords: number; hasNextPage: boolean }; + }; + expect(structured.charges).toEqual([ + { + id: 'c1', + description: 'Coffee', + amount: { value: 12.5, formatted: '₪12.50', currency: 'ILS' }, + date: '2026-01-05', + }, + ]); + expect(structured.pagination.totalRecords).toBe(1); + expect(structured.pagination.hasNextPage).toBe(false); + }); + + it('scopes the query to the authorized businesses (byBusinesses)', async () => { + let sentBody: unknown; + const client = clientReturning(oneCharge, body => (sentBody = body)); + await run(client, authContext(['b1', 'b2']), {}); + const variables = (sentBody as { variables: { filters: { byBusinesses: string[] } } }).variables; + expect(variables.filters.byBusinesses).toEqual(['b1', 'b2']); + }); + + it('narrows the scope to a requested subset', async () => { + let sentBody: unknown; + const client = clientReturning(oneCharge, body => (sentBody = body)); + await run(client, authContext(['b1', 'b2', 'b3']), { businessIds: ['b2'] }); + const variables = (sentBody as { variables: { filters: { byBusinesses: string[] } } }).variables; + expect(variables.filters.byBusinesses).toEqual(['b2']); + }); +}); + +describe('searchChargesTool — empty results', () => { + it('reports no matches', async () => { + const client = clientReturning({ + allCharges: { + nodes: [], + pageInfo: { totalPages: 0, totalRecords: 0, currentPage: 1, pageSize: 25 }, + }, + }); + const result = await run(client, authContext(['b1']), {}); + expect(result.isError).toBeUndefined(); + expect((result.structuredContent as { charges: unknown[] }).charges).toEqual([]); + expect(result.content[0].text).toMatch(/No charges/); + }); +}); + +describe('searchChargesTool — invalid filters', () => { + it('rejects an unknown field (VALIDATION_ERROR)', async () => { + const client = clientReturning(oneCharge); + const result = await run(client, authContext(['b1']), { bogus: true }); + expect(result.isError).toBe(true); + expect((result.structuredContent as { code: string }).code).toBe('VALIDATION_ERROR'); + }); + + it('rejects a bad date format', async () => { + const client = clientReturning(oneCharge); + const result = await run(client, authContext(['b1']), { fromDate: '01/01/2026' }); + expect(result.isError).toBe(true); + expect((result.structuredContent as { code: string }).code).toBe('VALIDATION_ERROR'); + }); + + it('rejects a single impossible date that still matches the format', async () => { + const client = clientReturning(oneCharge); + // Passes the regex but is an impossible calendar date; only fromDate given. + const result = await run(client, authContext(['b1']), { fromDate: '2026-13-01' }); + expect(result.isError).toBe(true); + expect((result.structuredContent as { message: string }).message).toBe('Invalid fromDate'); + }); + + it('rejects an inverted date range', async () => { + const client = clientReturning(oneCharge); + const result = await run(client, authContext(['b1']), { + fromDate: '2026-02-01', + toDate: '2026-01-01', + }); + expect(result.isError).toBe(true); + expect((result.structuredContent as { message: string }).message).toMatch(/on or before/); + }); + + it('rejects a page size above the cap', async () => { + const client = clientReturning(oneCharge); + const result = await run(client, authContext(['b1']), { pageSize: 500 }); + expect(result.isError).toBe(true); + expect((result.structuredContent as { code: string }).code).toBe('VALIDATION_ERROR'); + }); +}); + +describe('searchChargesTool — scope enforcement', () => { + it('denies a requested business outside the memberships (AUTHORIZATION_ERROR)', async () => { + const client = clientReturning(oneCharge); + const result = await run(client, authContext(['b1']), { businessIds: ['bX'] }); + expect(result.isError).toBe(true); + expect((result.structuredContent as { code: string }).code).toBe('AUTHORIZATION_ERROR'); + }); + + it('denies a caller with no business memberships', async () => { + const client = clientReturning(oneCharge); + const result = await run(client, authContext([]), {}); + expect(result.isError).toBe(true); + expect((result.structuredContent as { code: string }).code).toBe('AUTHORIZATION_ERROR'); + }); +}); + +describe('searchChargesTool — upstream failure', () => { + it('maps an upstream error to a retryable UPSTREAM/TIMEOUT result', async () => { + const fetchImpl = vi.fn(async () => ({ ok: false, status: 500, json: async () => ({}) }) as unknown as Response); + const client = new UpstreamGraphQLClient({ + endpoint: 'http://localhost:4000/graphql', + timeoutMs: 1000, + maxRetries: 0, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + const result = await run(client, authContext(['b1']), {}); + expect(result.isError).toBe(true); + const structured = result.structuredContent as { code: string; retryable: boolean }; + expect(structured.code).toBe('UPSTREAM_ERROR'); + expect(structured.retryable).toBe(true); + }); +}); diff --git a/packages/mcp-server/src/tools/charges.ts b/packages/mcp-server/src/tools/charges.ts new file mode 100644 index 000000000..eea6258e7 --- /dev/null +++ b/packages/mcp-server/src/tools/charges.ts @@ -0,0 +1,190 @@ +import { z } from 'zod'; +import { ToolInputError } from './execute.js'; +import type { ToolDefinition, ToolExecutionContext, ToolResult } from './registry.js'; + +/** + * Tool 1: read-only charges search/browse (spec §8.2). + * + * Results are always scoped to the caller's authorized businesses (the resolved + * read scope), with bounded pagination and a bounded date range. + */ + +export const SEARCH_CHARGES_TOOL_NAME = 'accounter_search_charges'; + +/** Hard caps to keep responses bounded (spec §9.1, §9.3). */ +export const MAX_PAGE_SIZE = 50; +export const DEFAULT_PAGE_SIZE = 25; +export const MAX_DATE_RANGE_DAYS = 366; + +const TIMELESS_DATE = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be in YYYY-MM-DD format'); + +const searchChargesInput = z.object({ + businessIds: z + .array(z.string().min(1)) + .max(50) + .optional() + .describe('Narrow results to these business ids (must be a subset of your memberships).'), + fromDate: TIMELESS_DATE.optional().describe('Only charges on/after this date (YYYY-MM-DD).'), + toDate: TIMELESS_DATE.optional().describe('Only charges on/before this date (YYYY-MM-DD).'), + tags: z.array(z.string().min(1)).max(20).optional().describe('Only charges carrying these tags.'), + freeText: z.string().min(1).max(200).optional().describe('Free-text search across the charge.'), + flow: z + .enum(['ALL', 'INCOME', 'EXPENSE']) + .optional() + .default('ALL') + .describe('Restrict to income or expense charges.'), + page: z.number().int().positive().optional().default(1), + pageSize: z.number().int().positive().max(MAX_PAGE_SIZE).optional().default(DEFAULT_PAGE_SIZE), +}); + +type SearchChargesInput = z.infer; + +const SEARCH_CHARGES_QUERY = /* GraphQL */ ` + query McpSearchCharges($filters: ChargeFilter, $page: Int!, $limit: Int!) { + allCharges(filters: $filters, page: $page, limit: $limit) { + nodes { + id + userDescription + totalAmount { + raw + formatted + currency + } + minEventDate + } + pageInfo { + totalPages + totalRecords + currentPage + pageSize + } + } + } +`; + +interface RawCharge { + id: string; + userDescription: string | null; + totalAmount: { raw: number; formatted: string; currency: string } | null; + minEventDate: string | null; +} + +interface SearchChargesData { + allCharges: { + nodes: RawCharge[]; + pageInfo: { + totalPages: number; + totalRecords: number; + currentPage: number | null; + pageSize: number | null; + }; + }; +} + +/** Normalized charge shape returned to the caller. */ +export interface NormalizedCharge { + id: string; + description: string | null; + amount: { value: number; formatted: string; currency: string } | null; + date: string | null; +} + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** Reject an invalid, inverted, or too-wide date range before hitting upstream. */ +function assertDateRange(input: SearchChargesInput): void { + // Validate each supplied date even when only one is present — a value can + // match the format regex yet be an impossible calendar date (e.g. 2026-02-31). + const from = input.fromDate ? Date.parse(input.fromDate) : undefined; + const to = input.toDate ? Date.parse(input.toDate) : undefined; + if (from !== undefined && Number.isNaN(from)) { + throw new ToolInputError('Invalid fromDate'); + } + if (to !== undefined && Number.isNaN(to)) { + throw new ToolInputError('Invalid toDate'); + } + if (from !== undefined && to !== undefined) { + if (from > to) { + throw new ToolInputError('fromDate must be on or before toDate'); + } + if (Math.round((to - from) / DAY_MS) > MAX_DATE_RANGE_DAYS) { + throw new ToolInputError(`Date range must not exceed ${MAX_DATE_RANGE_DAYS} days`); + } + } +} + +function buildFilters(input: SearchChargesInput, businessIds: readonly string[]) { + const filters: Record = { chargesType: input.flow }; + // Always scope to the authorized businesses. + if (businessIds.length > 0) { + filters.byBusinesses = [...businessIds]; + } + if (input.fromDate) filters.fromDate = input.fromDate; + if (input.toDate) filters.toDate = input.toDate; + if (input.tags && input.tags.length > 0) filters.byTags = input.tags; + if (input.freeText) filters.freeText = input.freeText; + return filters; +} + +function normalizeCharge(charge: RawCharge): NormalizedCharge { + return { + id: charge.id, + description: charge.userDescription, + amount: charge.totalAmount + ? { + value: charge.totalAmount.raw, + formatted: charge.totalAmount.formatted, + currency: charge.totalAmount.currency, + } + : null, + date: charge.minEventDate, + }; +} + +async function handler( + input: SearchChargesInput, + context: ToolExecutionContext, +): Promise { + assertDateRange(input); + + const data = await context.client.query( + { + query: SEARCH_CHARGES_QUERY, + variables: { + filters: buildFilters(input, context.readScope.businessIds), + page: input.page, + limit: input.pageSize, + }, + }, + { correlationId: context.correlationId, authorization: context.authorization }, + ); + + const charges = data.allCharges.nodes.map(normalizeCharge); + const { pageInfo } = data.allCharges; + const pagination = { + page: pageInfo.currentPage ?? input.page, + pageSize: pageInfo.pageSize ?? input.pageSize, + totalPages: pageInfo.totalPages, + totalRecords: pageInfo.totalRecords, + hasNextPage: (pageInfo.currentPage ?? input.page) < pageInfo.totalPages, + }; + + const summary = + charges.length === 0 + ? 'No charges matched the given filters.' + : `Found ${pagination.totalRecords} charge(s); showing page ${pagination.page} of ${pagination.totalPages} (${charges.length} on this page).`; + + return { + content: [{ type: 'text', text: summary }], + structuredContent: { charges, pagination }, + }; +} + +export const searchChargesTool: ToolDefinition = { + name: SEARCH_CHARGES_TOOL_NAME, + description: + 'Search and browse accounting charges within your authorized businesses. Supports date range, tag, free-text, and income/expense filters with bounded pagination. Read-only.', + inputSchema: searchChargesInput, + policy: { requiresBusinessScope: true, dataClassification: 'business' }, + handler, +}; diff --git a/packages/mcp-server/src/tools/execute.ts b/packages/mcp-server/src/tools/execute.ts new file mode 100644 index 000000000..8946300b0 --- /dev/null +++ b/packages/mcp-server/src/tools/execute.ts @@ -0,0 +1,166 @@ +import type { McpAuthContext } from '../auth/identity.js'; +import { log } from '../logger.js'; +import { UpstreamError } from '../upstream/graphql-client.js'; +import type { UpstreamGraphQLClient } from '../upstream/graphql-client.js'; +import { evaluateToolPolicy } from './policy.js'; +import { + validateToolInput, + type ToolDefinition, + type ToolExecutionContext, + type ToolResult, + type ToolValidationIssue, +} from './registry.js'; + +/** + * Curated tool execution: input validation → authorization policy → handler, + * with deterministic error mapping to the spec's error taxonomy (§10.2). + * + * Tool-execution failures are returned as an MCP tool result with `isError` + * and a structured `{ code, message, correlationId, retryable? }` payload (the + * unified error mapper in a later step formalizes this shape) rather than as + * protocol-level JSON-RPC errors, so the model can read them. + */ + +/** Machine error codes surfaced to tool callers (spec §10.2). */ +export type ToolErrorCode = + | 'VALIDATION_ERROR' + | 'AUTHORIZATION_ERROR' + | 'UPSTREAM_ERROR' + | 'TIMEOUT_ERROR' + | 'INTERNAL_ERROR'; + +/** + * A domain-validation failure a handler can throw (e.g. cross-field bounds not + * expressible in the input schema). Mapped to a VALIDATION_ERROR result. + */ +export class ToolInputError extends Error { + public readonly code = 'VALIDATION_ERROR'; + + constructor( + message: string, + public readonly issues?: ToolValidationIssue[], + ) { + super(message); + this.name = 'ToolInputError'; + } +} + +interface ToolErrorDetails { + code: ToolErrorCode; + message: string; + correlationId: string; + retryable?: boolean; + issues?: ToolValidationIssue[]; +} + +/** Build an MCP tool-result error carrying the taxonomy fields. */ +export function toolErrorResult(details: ToolErrorDetails): ToolResult { + return { + content: [{ type: 'text', text: `${details.code}: ${details.message}` }], + isError: true, + structuredContent: { + code: details.code, + message: details.message, + correlationId: details.correlationId, + ...(details.retryable !== undefined && { retryable: details.retryable }), + ...(details.issues && details.issues.length > 0 && { issues: details.issues }), + }, + }; +} + +/** Optional per-tool convention: an input field carrying scope narrowing. */ +function requestedBusinessIds(input: unknown): string[] | undefined { + if (input && typeof input === 'object') { + const value = (input as { businessIds?: unknown }).businessIds; + if (Array.isArray(value) && value.every(id => typeof id === 'string')) { + return value as string[]; + } + } + return undefined; +} + +export interface ExecuteToolParams { + tool: ToolDefinition; + rawArgs: unknown; + auth: McpAuthContext; + correlationId: string; + client: UpstreamGraphQLClient; + authorization?: string; +} + +/** + * Validate, authorize, and execute a registered tool. Always resolves to a + * {@link ToolResult} — success or a taxonomy-tagged error result. + */ +export async function executeRegisteredTool(params: ExecuteToolParams): Promise { + const { tool, rawArgs, auth, correlationId, client, authorization } = params; + + // 1. Strict input validation (unknown fields rejected). + const validation = validateToolInput(tool, rawArgs); + if (!validation.ok) { + return toolErrorResult({ + code: 'VALIDATION_ERROR', + message: validation.error.message, + correlationId, + issues: validation.error.issues, + }); + } + const input = validation.data; + + // 2. Authorization policy (roles + business scope narrowing). + const decision = evaluateToolPolicy({ + policy: tool.policy, + auth, + requestedBusinessIds: requestedBusinessIds(input), + }); + if (!decision.allowed) { + return toolErrorResult({ + code: 'AUTHORIZATION_ERROR', + message: decision.error.message, + correlationId, + }); + } + + // 3. Execute the handler. + const context: ToolExecutionContext = { + auth, + readScope: decision.readScope, + correlationId, + client, + authorization, + }; + try { + return await tool.handler(input, context); + } catch (error) { + if (error instanceof ToolInputError) { + return toolErrorResult({ + code: 'VALIDATION_ERROR', + message: error.message, + correlationId, + issues: error.issues, + }); + } + if (error instanceof UpstreamError) { + return toolErrorResult({ + code: error.code, + message: error.message, + correlationId, + retryable: error.retryable, + }); + } + // Unexpected failure — log it (with the tool + correlation id) so the + // observability gap doesn't hide production bugs; the caller only sees a + // generic message. + log('error', 'unexpected error during tool execution', { + tool: tool.name, + correlationId, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }); + return toolErrorResult({ + code: 'INTERNAL_ERROR', + message: 'Tool execution failed', + correlationId, + }); + } +} diff --git a/packages/mcp-server/src/tools/registry-instance.ts b/packages/mcp-server/src/tools/registry-instance.ts new file mode 100644 index 000000000..7bd4c0ea4 --- /dev/null +++ b/packages/mcp-server/src/tools/registry-instance.ts @@ -0,0 +1,10 @@ +import { searchChargesTool } from './charges.js'; +import { ToolRegistry } from './registry.js'; + +/** + * The process-wide registry of curated production tools. Tools register here as + * they are added; the MCP transport lists and dispatches from this instance. + */ +export const toolRegistry = new ToolRegistry(); + +toolRegistry.register(searchChargesTool); diff --git a/packages/mcp-server/src/tools/registry.ts b/packages/mcp-server/src/tools/registry.ts index 7aac54c51..a52c37ac4 100644 --- a/packages/mcp-server/src/tools/registry.ts +++ b/packages/mcp-server/src/tools/registry.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import type { AuthorizedReadScope, McpAuthContext } from '../auth/identity.js'; +import type { UpstreamGraphQLClient } from '../upstream/graphql-client.js'; /** * Curated tool registry abstraction. @@ -34,6 +35,10 @@ export interface ToolExecutionContext { readScope: AuthorizedReadScope; /** Correlation id for tracing/log/upstream propagation. */ correlationId: string; + /** Shared upstream GraphQL client for read-only operations. */ + client: UpstreamGraphQLClient; + /** Caller's Authorization header, forwarded upstream (never logged). */ + authorization?: string; } export interface ToolTextContent {