From a5c04e9c9bf8d7dea7119dd82d7c744810a05a5f Mon Sep 17 00:00:00 2001 From: Gil Gardosh Date: Wed, 15 Jul 2026 18:37:11 +0300 Subject: [PATCH 01/38] docs --- docs/mcp/implementation-blueprint.md | 828 +++++++++++++++++++++++++++ docs/mcp/spec.md | 528 +++++++++++++++++ 2 files changed, 1356 insertions(+) create mode 100644 docs/mcp/implementation-blueprint.md create mode 100644 docs/mcp/spec.md diff --git a/docs/mcp/implementation-blueprint.md b/docs/mcp/implementation-blueprint.md new file mode 100644 index 000000000..0ae3baec4 --- /dev/null +++ b/docs/mcp/implementation-blueprint.md @@ -0,0 +1,828 @@ +# Accounter MCP Implementation Blueprint and Incremental Prompt Pack + +Status: Draft for execution Related spec: docs/mcp/spec.md Last updated: 2026-07-15 + +## 1) How to use this document + +This is an execution plan for building the MCP connector defined in docs/mcp/spec.md. + +It is intentionally iterative: + +1. Start with a full end-to-end blueprint. +2. Decompose into implementation chunks. +3. Decompose again into right-sized steps. +4. Use the prompt pack to drive a code-generation LLM through safe, incremental implementation. + +Each prompt is designed to build on the previous prompt with no orphaned code. + +## 2) Foundation constraints + +- Phase 1 target: hosted Claude surfaces (Claude.ai/Desktop) +- Auth stack: Auth0 OAuth, CIMD-first +- Scope: read-only tools only +- Package boundary: new package at packages/mcp-server +- Metadata hosting: same domain plus explicit 401 resource_metadata pointer +- Authorization source of truth: existing server auth and business membership model +- No generic GraphQL passthrough tool + +## 3) End-to-end blueprint (single-pass view) + +## 3.1 Workstream A: Runtime and package foundation + +1. Create packages/mcp-server package scaffold. +2. Add TypeScript config, linting, tests, and scripts with yarn workspace conventions. +3. Build HTTP server entrypoint with graceful shutdown and health route. +4. Add configuration loader with strict environment validation. + +Exit condition: + +- Service starts in local/dev mode, health endpoint works, tests run. + +## 3.2 Workstream B: MCP transport and protocol skeleton + +1. Implement Streamable HTTP MCP endpoint. +2. Implement protocol handshake and a minimal tool listing path. +3. Add request/response context with correlation id. +4. Add deterministic stub tool for smoke testing. + +Exit condition: + +- MCP endpoint is reachable and can list at least one test tool. + +## 3.3 Workstream C: OAuth discovery and authentication + +1. Serve protected resource metadata on well-known path. +2. Add standards-compliant 401 challenge with WWW-Authenticate and resource_metadata pointer. +3. Implement bearer token validator using Auth0 issuer/audience/JWKS. +4. Add claim mapping into internal auth identity model. + +Exit condition: + +- Unauthenticated calls get correct challenge. +- Valid token establishes authenticated request context. + +## 3.4 Workstream D: Authorization and tool-policy enforcement + +1. Build tool policy model (roles, scope, redaction level). +2. Enforce business membership and scope narrowing constraints. +3. Add per-tool auth checks before execution. +4. Add explicit deny behavior for unauthorized scope. + +Exit condition: + +- Cross-tenant and unauthorized role requests are rejected deterministically. + +## 3.5 Workstream E: Upstream GraphQL orchestration + +1. Build GraphQL client abstraction with timeout, retry rules, and error translation. +2. Add typed operation wrappers for phase 1 read-only operations. +3. Normalize output shape and pagination behavior. +4. Add output limits and truncation continuation hints. + +Exit condition: + +- At least two production tools return bounded, safe results. + +## 3.6 Workstream F: Production hardening + +1. Implement full error taxonomy and mapping. +2. Add structured logging and metrics. +3. Add optional tracing spans and correlation propagation. +4. Add rate limiting by user, business, tool. + +Exit condition: + +- Failure modes are observable and controlled. + +## 3.7 Workstream G: Testing, release, and directory readiness + +1. Unit tests for auth, policy, schema validation, and mappers. +2. Integration tests for OAuth challenge flow and tool execution. +3. Security tests for tenant isolation and malformed input. +4. Performance checks for latency and timeout budgets. +5. Submission readiness checklist and ops runbook. + +Exit condition: + +- Connector can be validated for directory submission with predictable behavior. + +## 4) Decomposition round 1: delivery chunks (epic-sized) + +This first decomposition groups work into medium chunks that can be assigned to one engineer per 1-3 +days. + +### Chunk R1-1: Package setup and runtime skeleton + +- Includes workstreams A and part of B +- Estimated size: 1.5-2 days +- Risk: low + +### Chunk R1-2: MCP protocol baseline + +- Remaining B +- Estimated size: 1-1.5 days +- Risk: low + +### Chunk R1-3: OAuth discovery and token validation + +- Workstream C +- Estimated size: 2-3 days +- Risk: medium + +### Chunk R1-4: Policy enforcement layer + +- Workstream D +- Estimated size: 1.5-2 days +- Risk: medium + +### Chunk R1-5: First toolset integration + +- Workstream E +- Estimated size: 2-3 days +- Risk: medium + +### Chunk R1-6: Hardening and observability + +- Workstream F +- Estimated size: 2 days +- Risk: medium + +### Chunk R1-7: Test matrix and release prep + +- Workstream G +- Estimated size: 2-3 days +- Risk: medium + +Assessment: good strategic grouping, but still too large for safe LLM code generation in one prompt +per chunk. + +## 5) Decomposition round 2: implementation slices (story-sized) + +This decomposition splits each epic into slices that can usually be done in a single focused PR. + +### Slice S2-1: package scaffold and scripts + +### Slice S2-2: env schema and config accessors + +### Slice S2-3: HTTP server bootstrap and health route + +### Slice S2-4: MCP endpoint shell and list-tools stub + +### Slice S2-5: request context and correlation ids + +### Slice S2-6: well-known metadata route + +### Slice S2-7: 401 challenge middleware + +### Slice S2-8: Auth0 token verifier module + +### Slice S2-9: identity mapping and auth context + +### Slice S2-10: tool registry abstraction and schema contracts + +### Slice S2-11: authorization policy checks per tool + +### Slice S2-12: GraphQL client with timeout/retry guardrails + +### Slice S2-13: first production tool (charges query) + +### Slice S2-14: second production tool (tags/tax categories) + +### Slice S2-15: third production tool (selected report read) + +### Slice S2-16: output shaping, limits, truncation helpers + +### Slice S2-17: error taxonomy and mapper integration + +### Slice S2-18: rate limiting middleware + +### Slice S2-19: structured logging and metrics + +### Slice S2-20: integration and security tests + +### Slice S2-21: directory readiness docs and runbook + +Assessment: much better, but still some slices mix too many moving parts (for example S2-12, S2-20). + +## 6) Decomposition round 3: right-sized steps (final execution sequence) + +This is the final, implementation-safe sequence. Each step is intended to be small enough for +low-risk implementation and review while still moving the project forward. + +## 6.1 Final step list + +1. Create package skeleton at packages/mcp-server with package.json, tsconfig, src/index.ts, and + basic yarn scripts. +2. Add local lint/test/build scripts and wire into workspace without affecting existing packages. +3. Add env schema module and fail-fast startup validation. +4. Add minimal HTTP server bootstrap and /health route. +5. Add graceful shutdown and error-safe process handlers. +6. Add MCP route shell with request parsing and placeholder response. +7. Add MCP list-tools response with one internal smoke tool. +8. Add request context utility with request id and correlation id propagation. +9. Add structured logger wrapper and request logging middleware. +10. Add protected resource metadata route under well-known path. +11. Add universal unauthenticated response helper returning 401 + WWW-Authenticate resource_metadata + pointer. +12. Add bearer token extraction and validation middleware boundary. +13. Implement Auth0 JWKS token verification module. +14. Map token claims to internal authenticated user context. +15. Add auth guard to MCP tool execution path. +16. Implement tool registry type contracts (name, schema, auth policy, handler). +17. Implement input schema validator adapter for tool invocations. +18. Implement authorization policy evaluator (role + business scope checks). +19. Implement GraphQL upstream client with timeout and non-destructive retry rules. +20. Implement upstream error classifier and safe message sanitizer. +21. Implement first tool: read-only charges search/browse with bounded pagination. +22. Implement second tool: tag and tax-category lookup with bounded output. +23. Implement third tool: selected report read operation with strict input bounds. +24. Implement output shaping helpers and partial-result continuation token format. +25. Add tool-level output size guardrails and truncation behavior. +26. Add final error taxonomy mapper across transport/auth/tool/upstream errors. +27. Add per-user/per-business/per-tool rate limiting middleware. +28. Add metrics counters and latency histograms. +29. Add trace hooks and propagation to upstream GraphQL calls. +30. Add unit tests for config/auth/policy/error/output helpers. +31. Add integration tests for 401 challenge, auth success, and tool invocation flow. +32. Add security tests for cross-tenant denial and malformed input handling. +33. Add performance test for latency and timeout behavior under read workloads. +34. Add connector runbook, deployment notes, and submission readiness checklist. +35. Add end-to-end local validation script and final wiring checklist. + +## 6.2 Why this is right-sized + +- Most steps fit in one focused PR. +- Each step has clear preconditions from previous steps. +- No step introduces more than one architectural concern at once. +- Tool implementation starts only after auth, policy, and upstream client foundations exist. +- Observability and tests are integrated before final release prep. + +## 7) Dependency map for safe sequencing + +- Steps 1-5 must complete before 6-9. +- Steps 10-15 must complete before any production tool steps. +- Steps 16-20 must complete before steps 21-25. +- Steps 26-29 should complete before full integration testing. +- Steps 30-35 close quality and release readiness. + +## 8) Prompt pack for code-generation LLM + +Use prompts in strict order. Do not skip ahead. Each prompt assumes previous prompts are implemented +and committed. + +--- + +## Prompt 01 - Scaffold package and scripts + +```text +You are implementing Prompt 01 of an incremental MCP build. + +Goal: +Create a new package at packages/mcp-server with minimal TypeScript scaffolding and workspace scripts. + +Requirements: +- Add package.json with build, dev, lint, test scripts. +- Add tsconfig.json aligned with monorepo TypeScript standards. +- Add src/index.ts with a no-op startup entrypoint. +- Ensure yarn workspace commands can run for this package. +- Do not modify unrelated packages. + +Constraints: +- Use yarn only. +- Keep implementation minimal and compilable. +- No runtime framework lock-in yet. + +Validation: +- yarn workspace mcp-server build passes. +- yarn workspace mcp-server test runs (can be placeholder). + +Output: +- Show exact files created/updated and why. +``` + +## Prompt 02 - Add strict env config + +```text +You are implementing Prompt 02. + +Goal: +Add strict environment validation and config access for mcp-server. + +Requirements: +- Create config/env module using schema validation. +- Include required vars for MCP host/port, auth issuer/audience/jwks, upstream GraphQL URL/timeouts. +- Fail fast on startup when env is invalid. +- Export typed config object for use by server modules. + +Constraints: +- Keep defaults secure. +- Do not embed secrets. + +Validation: +- Unit tests for valid and invalid env scenarios. +- Startup fails with clear errors when required vars are missing. + +Output: +- Include a short env variable table in comments or docs for this package. +``` + +## Prompt 03 - HTTP server bootstrap and health + +```text +You are implementing Prompt 03. + +Goal: +Stand up a minimal HTTP server with health endpoint and graceful shutdown hooks. + +Requirements: +- Add HTTP server bootstrap in src/index.ts or src/server.ts. +- Add GET /health endpoint returning service status and version. +- Add graceful shutdown for SIGINT/SIGTERM. +- Add centralized top-level error handling for startup failures. + +Constraints: +- Keep dependencies minimal. +- No MCP protocol logic yet. + +Validation: +- Server starts locally. +- /health returns 200. +- Shutdown logs and exits cleanly. +``` + +## Prompt 04 - MCP route shell and list-tools stub + +```text +You are implementing Prompt 04. + +Goal: +Add a basic MCP transport route and return a valid list-tools response with one internal smoke tool. + +Requirements: +- Add MCP endpoint path with request dispatch skeleton. +- Implement list-tools handling. +- Return one smoke tool entry (non-production tool). +- Return deterministic unsupported-method errors for unknown operations. + +Constraints: +- Keep this auth-agnostic for now. +- No upstream GraphQL calls yet. + +Validation: +- MCP inspector or local request can list tools. +- Unknown method returns stable error shape. +``` + +## Prompt 05 - Request context and structured logging foundation + +```text +You are implementing Prompt 05. + +Goal: +Introduce request context and structured logs for observability groundwork. + +Requirements: +- Add request id and correlation id generation/extraction utility. +- Propagate context through request lifecycle. +- Add structured logger wrapper with level, request id, correlation id, route/method, latency. +- Add middleware/hooks to log request start/end and failures. + +Constraints: +- Never log secrets or authorization headers. + +Validation: +- Logs include correlation_id and request_id. +- Error logs include machine-readable fields. +``` + +## Prompt 06 - Protected resource metadata route + +```text +You are implementing Prompt 06. + +Goal: +Add OAuth protected resource metadata endpoint on well-known path. + +Requirements: +- Serve /.well-known/oauth-protected-resource. +- Include resource value matching MCP public URL. +- Include authorization_servers pointing to Auth0 issuer. +- Ensure JSON content-type and stable document shape. + +Constraints: +- Keep document generation config-driven. +- No hardcoded environment-specific URLs. + +Validation: +- Route returns 200 with valid JSON. +- Resource URL exactly matches configured MCP URL. +``` + +## Prompt 07 - 401 challenge helper with resource_metadata pointer + +```text +You are implementing Prompt 07. + +Goal: +Implement standardized unauthenticated response for MCP auth discovery. + +Requirements: +- Create reusable helper for 401 responses. +- Add WWW-Authenticate Bearer header with resource_metadata URL pointer. +- Use this helper for unauthenticated MCP calls. + +Constraints: +- Must be RFC-compliant enough for connector flows. +- Do not return tool-level errors for missing auth. + +Validation: +- Unauthenticated request returns 401 and expected header. +- Header URL points to the well-known metadata route. +``` + +## Prompt 08 - Token extraction and Auth0 verification module + +```text +You are implementing Prompt 08. + +Goal: +Add bearer token extraction and Auth0 JWT verification. + +Requirements: +- Parse Authorization: Bearer header. +- Verify token using Auth0 issuer, audience, and JWKS. +- Enforce token expiry and signature checks. +- Return typed auth principal object on success. + +Constraints: +- No token in query params. +- Never log raw token values. + +Validation: +- Unit tests: valid token accepted, wrong issuer/audience rejected, expired token rejected. +``` + +## Prompt 09 - Identity mapping to business scope + +```text +You are implementing Prompt 09. + +Goal: +Map verified token identity to internal user + business membership context. + +Requirements: +- Build auth context object containing user id, roles, authorized memberships, and default read scope. +- Reuse existing server-side identity/membership assumptions where possible. +- Add a clear failure mode when mapping cannot resolve a valid user. + +Constraints: +- Keep phase 1 read-only semantics. +- No write-target resolution needed yet. + +Validation: +- Unit/integration tests for user with multiple businesses and narrowed scope behavior. +``` + +## Prompt 10 - Tool registry contracts and input validation + +```text +You are implementing Prompt 10. + +Goal: +Create a production-ready tool registry abstraction. + +Requirements: +- Define tool contract type with name, description, input schema, auth policy, handler. +- Add registration API and lookup. +- Add strict input validation with unknown-field rejection. +- Add deterministic validation error payload. + +Constraints: +- Registry must support incremental tool additions. +- Keep handlers pure and testable. + +Validation: +- Unit tests for registration, duplicate names, and schema failures. +``` + +## Prompt 11 - Authorization policy evaluator + +```text +You are implementing Prompt 11. + +Goal: +Implement policy checks per tool before execution. + +Requirements: +- Evaluate required roles and business scope constraints. +- Deny requests outside authorized memberships with AUTHORIZATION_ERROR. +- Support optional caller-provided scope narrowing only if subset of authorized scope. + +Constraints: +- Policy logic must run before handler execution. +- Keep behavior deterministic and test-covered. + +Validation: +- Tests for allow, deny, and scope-subset edge cases. +``` + +## Prompt 12 - GraphQL upstream client with timeout/retry guardrails + +```text +You are implementing Prompt 12. + +Goal: +Add a shared upstream GraphQL client used by tool handlers. + +Requirements: +- Implement request function with typed query/mutation wrappers for read-only operations. +- Add timeout budget and cancellation. +- Retry only idempotent read failures (bounded attempts, no auth/validation retries). +- Propagate correlation id to upstream headers. + +Constraints: +- No generic execute-anything API exposed to tools. +- Sanitize upstream errors. + +Validation: +- Unit tests for timeout, retry eligibility, and correlation header propagation. +``` + +## Prompt 13 - Tool 1: charges search/browse (read-only) + +```text +You are implementing Prompt 13. + +Goal: +Implement the first production tool for read-only charges browsing/search. + +Requirements: +- Add tool registration and handler using upstream GraphQL client. +- Define strict input schema: filters, date range, page size, cursor/page token. +- Enforce max page size and bounded date ranges. +- Return normalized output shape with pagination metadata. + +Constraints: +- Read-only operations only. +- Respect business scope from auth context. + +Validation: +- Integration tests for successful read, empty results, and invalid filters. +``` + +## Prompt 14 - Tool 2: tags and tax-category lookup (read-only) + +```text +You are implementing Prompt 14. + +Goal: +Implement lookup tool(s) for tags and tax categories. + +Requirements: +- Add one or two tools depending on clean API design. +- Keep input minimal and output deterministic. +- Enforce output size caps and sort order stability. + +Constraints: +- No write behavior. +- Keep response fields limited to tool use cases. + +Validation: +- Integration tests for standard lookups and scope enforcement. +``` + +## Prompt 15 - Tool 3: selected report read operation + +```text +You are implementing Prompt 15. + +Goal: +Add one high-value report generation/read tool from approved phase 1 list. + +Requirements: +- Define strict schema with tight limits (date range, report type enum, pagination if needed). +- Call upstream read-only report query. +- Normalize and bound output for MCP result constraints. + +Constraints: +- Start with one report only. +- Avoid large unbounded payloads. + +Validation: +- Tests for valid report, invalid range, and oversized-result handling. +``` + +## Prompt 16 - Output shaping and truncation framework + +```text +You are implementing Prompt 16. + +Goal: +Implement reusable output shaping and truncation support for all tools. + +Requirements: +- Add centralized output formatter utilities. +- Add max payload guard with deterministic truncation behavior. +- Return continuation hints/tokens when truncation occurs. +- Ensure all existing tools use this shared formatter. + +Constraints: +- Preserve schema stability while truncating. +- Never cut JSON structure into invalid output. + +Validation: +- Unit tests for payload limit boundaries and continuation metadata. +``` + +## Prompt 17 - Unified error taxonomy and mapper + +```text +You are implementing Prompt 17. + +Goal: +Apply a single error taxonomy and mapper across transport, auth, policy, tool, and upstream failures. + +Requirements: +- Implement machine codes: VALIDATION_ERROR, AUTHENTICATION_ERROR, AUTHORIZATION_ERROR, UPSTREAM_ERROR, TIMEOUT_ERROR, RATE_LIMIT_ERROR, INTERNAL_ERROR. +- Map all existing error sources into taxonomy. +- Include correlation id and retryable boolean in error payload. + +Constraints: +- Do not leak stack traces or SQL/internal details. + +Validation: +- Unit tests for all mapping branches. +- Integration tests to verify consistent response shapes. +``` + +## Prompt 18 - Rate limiting middleware + +```text +You are implementing Prompt 18. + +Goal: +Add rate limiting per user, business, and tool. + +Requirements: +- Implement configurable limiter middleware. +- Enforce limits before expensive upstream calls. +- Return RATE_LIMIT_ERROR with retry guidance. + +Constraints: +- Start with in-memory limiter for phase 1 unless shared store already exists. +- Keep limiter keys scoped to authenticated identity + tool + business. + +Validation: +- Tests for allowed burst, limit exceeded, and reset behavior. +``` + +## Prompt 19 - Metrics and tracing integration + +```text +You are implementing Prompt 19. + +Goal: +Add operational telemetry required for production readiness. + +Requirements: +- Add request counters by tool/outcome. +- Add latency histogram. +- Add auth failure counters by reason. +- Add optional tracing spans around auth validation and upstream calls. +- Ensure correlation id propagates into logs and upstream requests. + +Constraints: +- Keep PII handling safe. + +Validation: +- Unit/integration checks that metrics increment correctly on success/failure. +``` + +## Prompt 20 - Unit test matrix completion + +```text +You are implementing Prompt 20. + +Goal: +Complete broad unit test coverage for core modules. + +Requirements: +- Cover env config validation. +- Cover token validation edge cases. +- Cover policy evaluator. +- Cover registry/schema validation. +- Cover error mapper and output truncation. + +Constraints: +- Tests should be deterministic and not call real external services. + +Validation: +- Test suite passes with meaningful branch coverage. +``` + +## Prompt 21 - Integration and security tests + +```text +You are implementing Prompt 21. + +Goal: +Add integration/security tests that verify end-to-end behavior. + +Requirements: +- Test 401 challenge and metadata discovery paths. +- Test authenticated tool invocation success. +- Test cross-tenant denial. +- Test malformed input and error taxonomy correctness. +- Test unauthorized role behavior. + +Constraints: +- Use fixtures/mocks for upstream GraphQL where appropriate. + +Validation: +- CI-ready integration suite with stable pass/fail signals. +``` + +## Prompt 22 - Performance and timeout validation + +```text +You are implementing Prompt 22. + +Goal: +Add practical performance and timeout tests for phase 1 workloads. + +Requirements: +- Add load test scenario for read-only tool calls. +- Validate timeout and retry behavior under delayed upstream responses. +- Confirm latency stays within team target under baseline concurrency. + +Constraints: +- Keep tests lightweight enough for regular execution or nightly profile. + +Validation: +- Produce a short benchmark summary artifact. +``` + +## Prompt 23 - Deployment docs, runbook, and submission checklist + +```text +You are implementing Prompt 23. + +Goal: +Finalize documentation and operational readiness for launch and directory submission. + +Requirements: +- Add package-level README for local run, env setup, and troubleshooting. +- Add operations runbook: incident handling, key metrics, log queries, rollback steps. +- Add connector submission readiness checklist aligned with current implementation. +- Document known limitations and phase 2 write-scope plan. + +Constraints: +- Keep docs accurate to implemented behavior only. + +Validation: +- New engineer can run service and execute smoke tests using docs alone. +``` + +## Prompt 24 - Final wiring and acceptance gate + +```text +You are implementing Prompt 24. + +Goal: +Perform final wiring audit and acceptance checks with no orphaned code. + +Requirements: +- Verify all registered tools are reachable through MCP endpoint. +- Verify every tool uses shared auth, policy, error, and output frameworks. +- Remove or integrate temporary smoke stubs. +- Add final acceptance script that runs lint, build, unit tests, integration tests, and smoke checks. +- Produce a concise release note summary. + +Constraints: +- No dead modules. +- No bypass paths around auth/policy. + +Validation: +- Acceptance script passes end-to-end. +- System is ready for controlled rollout. +``` + +## 9) Review checklist for each prompt execution + +Use this checklist after each prompt: + +- Is the change fully integrated into existing flow? +- Are tests added or updated for the new behavior? +- Are logs/metrics safe and useful? +- Is auth/policy enforced before data access? +- Is there any temporary code not wired into production path? + +If any answer is no, fix before moving to the next prompt. + +## 10) Suggested PR cadence + +- One prompt per PR for prompts 01-12. +- Prompts 13-16 may be one PR each due to behavior surface. +- Prompts 17-24 can be one PR per prompt or paired only when tightly related. + +Do not batch multiple prompts if it reduces reviewability or test clarity. diff --git a/docs/mcp/spec.md b/docs/mcp/spec.md new file mode 100644 index 000000000..9cfb7fc0e --- /dev/null +++ b/docs/mcp/spec.md @@ -0,0 +1,528 @@ +# Accounter MCP Connector Specification + +Status: Proposed Owner: Platform / Server Team Last Updated: 2026-07-15 + +## 1) Purpose + +Expose a safe, production-grade subset of Accounter server capabilities through a remote MCP server +so Claude clients can interact with business data using approved tools. + +This document is implementation-ready and defines: + +- Scope and rollout phases +- Authentication and authorization architecture +- Tool exposure strategy and data handling rules +- Error handling and observability requirements +- Testing and release criteria + +## 2) Confirmed Decisions + +These decisions were explicitly selected and are treated as baseline requirements: + +- Client surface for phase 1: Claude hosted surfaces (Claude.ai/Desktop) +- Authentication baseline: Auth0-based OAuth using existing identity model +- OAuth connector variant: CIMD preferred +- Initial capability scope: read-only tools only +- Deployment model: new monorepo package for MCP server +- Connector goal: directory-ready in near term +- Metadata hosting: same domain as MCP server with well-known routes +- Identity mapping: reuse existing Auth0 users and business memberships + +## 3) Goals and Non-Goals + +### 3.1 Goals + +- Deliver a secure MCP endpoint with OAuth-based user consent. +- Reuse existing multi-tenant authorization model from server. +- Expose high-value read-only capabilities first. +- Provide strict tenant isolation and robust auditability. +- Be compatible with connector directory requirements. + +### 3.2 Non-Goals (Phase 1) + +- No destructive or financial write operations. +- No bypass of existing role/business membership checks. +- No parallel REST API creation. +- No broad GraphQL passthrough tool. + +## 4) Existing Platform Facts to Reuse + +- GraphQL server is the canonical business API surface. +- Multi-tenant scoping exists via business memberships and read/write scope helpers. +- API key and auth providers already exist, but OAuth user auth is preferred for hosted connector + UX. +- Server architecture already uses operation-scoped providers and DI. +- Existing gateway package pattern can be reused for isolated protocol adapters. + +## 5) High-Level Architecture + +## 5.1 Package and Runtime Layout + +Create a new package: + +- packages/mcp-server + +Primary components: + +- HTTP transport layer implementing remote MCP server (Streamable HTTP) +- OAuth discovery/auth metadata endpoints +- Auth middleware (bearer token validation and identity extraction) +- Tool registry and input validation layer +- GraphQL orchestration client (calls existing server APIs) +- Output shaping/sanitization and truncation guard +- Structured logging, metrics, tracing hooks + +## 5.2 Control Flow + +1. Claude connects to MCP endpoint. +2. If unauthenticated, MCP returns 401 with WWW-Authenticate and resource metadata pointer. +3. Claude completes OAuth flow against Auth0 (CIMD path). +4. MCP validates access token. +5. Tool invocation arrives at MCP server. +6. MCP validates tool name + input schema + scope constraints. +7. MCP calls existing GraphQL operation(s). +8. MCP sanitizes/normalizes output, applies output size constraints. +9. MCP returns tool result with deterministic error mapping. + +## 5.3 Dependency Direction + +- mcp-server depends on server API contracts and shared auth utilities. +- mcp-server does not become a second source of business logic. +- Business rules remain in existing server modules/providers. + +## 6) Authentication and Metadata Hosting + +## 6.1 OAuth Mode + +Use OAuth with Auth0 and CIMD. + +Why: + +- Avoid DCR client explosion at scale. +- Better operational stability for directory traffic. +- Preserves user-consent model and per-user access control. + +## 6.2 Metadata Hosting Decision + +Primary mode: same domain as MCP server for well-known routes. + +Serve on MCP origin: + +- /.well-known/oauth-protected-resource +- (If needed by deployment) path-specific protected resource document + +Additionally, always return 401 with explicit: + +- WWW-Authenticate: Bearer + resource_metadata="https:///.well-known/oauth-protected-resource" + +This explicit pointer is required for reliability and prevents discovery failures in edge routing +scenarios. + +## 6.3 Callback and Discovery Requirements + +Auth server must support: + +- Standard OAuth discovery metadata endpoints +- PKCE S256 +- Form-url-encoded token endpoint requests +- Refresh-token flows compatible with public clients + +Hosted Claude redirect URI to allow: + +- https://claude.ai/api/mcp/auth_callback + +If Claude Code support is added later, also support loopback callback patterns as a separate phase. + +## 6.4 Token and Scope Model + +Access token claims must map to: + +- user identity +- tenant/business memberships +- role claims or resolvable roles from server + +Scope strategy: + +- Keep OAuth scopes coarse for transport access. +- Enforce fine-grained capability authorization in MCP tool authorization layer. + +## 6.5 Security Constraints + +- Never accept credentials in URL query params. +- Enforce HTTPS only. +- Reject missing/invalid token with 401 and standards-compliant headers. +- Keep auth endpoint latency comfortably under published connector limits. + +## 7) Authorization and Tenant Isolation + +## 7.1 Source of Truth + +Authorization source of truth remains existing server logic: + +- role checks +- business membership checks +- read/write scope resolution helpers + +## 7.2 Tool-Level Access Policy + +Each tool must define: + +- required role set +- allowed business scope behavior +- data-classification level +- redaction policy + +Phase 1 policy: + +- Read-only tools +- Must require authenticated user context +- Must restrict data to authorized business scope + +## 7.3 Scope Narrowing + +Support explicit scope narrowing input (when needed), but only as subset of authorized memberships. + +Rules: + +- Requested scope outside memberships => forbidden +- Empty requested scope => default authorized read scope + +## 8) Tool Exposure Strategy + +## 8.1 Selection Framework + +Expose only capabilities that pass all checks: + +- Read-only +- High utility for assistant workflows +- Deterministic and bounded response shape +- Low compliance/destructive risk +- Strong existing authorization path + +## 8.2 Initial Tool Groups (Phase 1) + +Recommended initial groups: + +- Charges browse/search (read-only) +- Tags and tax-category lookups +- Counterparty/business entity lookups +- Selected report generation queries (read-only) +- Ledger/query inspection (read-only) + +Excluded in phase 1: + +- Any merge/delete/batch destructive operation +- Ledger regeneration/locking +- Salary and payroll writes +- Provider credential operations +- Scraper or ingestion mutation surfaces + +## 8.3 Tool Contract Design + +For each tool define: + +- name +- plain-language description +- strict JSON input schema +- deterministic output schema +- authorization policy +- pagination and filtering behavior +- max result size and truncation behavior + +Avoid a generic runGraphQL tool because it bypasses curated safety controls. + +## 9) Data Handling Specification + +## 9.1 Input Validation + +- Validate all tool inputs against strict schemas. +- Reject unknown fields by default. +- Apply server-side bounds on page size, date range, and query breadth. + +## 9.2 Output Shaping + +- Return only fields needed for tool use. +- Redact or omit secrets and internal-only metadata. +- Normalize timestamps, currency formatting, and nullable fields. + +## 9.3 Size Limits and Truncation + +Respect client constraints by design: + +- Limit output payload length proactively. +- If data exceeds limits, return structured partial result with continuation hints. + +## 9.4 PII and Sensitive Data + +Define data classes: + +- Public operational metadata +- Business-sensitive financial data +- High-sensitivity identity/credential data + +Phase 1 tools must avoid high-sensitivity credential domains. + +## 9.5 Caching + +- Allow short-lived in-process cache only for non-sensitive metadata and schema descriptors. +- No cross-user cache key collisions. +- Cache keys must include user and business scope dimensions where relevant. + +## 10) Error Handling Strategy + +## 10.1 Transport/Auth Errors + +- 401 Unauthorized for missing/invalid token +- Include WWW-Authenticate header and resource_metadata pointer +- 403 Forbidden for authenticated but unauthorized scope/capability + +## 10.2 Tool Execution Errors + +Error taxonomy: + +- VALIDATION_ERROR: invalid input +- AUTHENTICATION_ERROR: token/session problem +- AUTHORIZATION_ERROR: scope/role violation +- UPSTREAM_ERROR: GraphQL/server downstream failure +- TIMEOUT_ERROR: upstream timeout +- RATE_LIMIT_ERROR: request throttled +- INTERNAL_ERROR: uncategorized failure + +Each error response must include: + +- stable machine code +- human-readable message safe for end users +- correlation id +- retryability hint + +## 10.3 Upstream GraphQL Error Mapping + +- Map GraphQL auth errors to auth categories above. +- Preserve business-safe details only. +- Remove stack traces and internal SQL details. + +## 10.4 Timeouts and Retries + +- Enforce strict upstream request timeout budget. +- Retry only idempotent read operations with bounded attempts. +- Never retry on authorization or validation failures. + +## 11) Observability and Operations + +## 11.1 Logging + +Structured logs for every request: + +- timestamp +- correlation_id +- request_id +- user_id (or pseudonymous id) +- business_scope +- tool_name +- outcome code +- latency_ms + +Never log: + +- access tokens +- refresh tokens +- raw secrets + +## 11.2 Metrics + +Required metrics: + +- mcp_requests_total by tool and outcome +- mcp_request_latency_ms histogram +- auth_failures_total by reason +- upstream_graphql_errors_total by category +- rate_limited_total + +## 11.3 Tracing + +- Propagate correlation id to upstream GraphQL calls. +- Attach trace spans around auth validation and tool execution. + +## 11.4 Rate Limiting + +Apply per: + +- user +- business +- tool + +Start conservative for phase 1; tune after observed production behavior. + +## 12) Implementation Plan (Developer-Ready) + +## 12.1 Phase 0: Foundations + +1. Scaffold packages/mcp-server with TS build, lint, test setup. +2. Implement Streamable HTTP MCP transport endpoint. +3. Implement health endpoint and graceful shutdown. +4. Add env schema and configuration loader. + +Deliverable: + +- running MCP server skeleton with no tools. + +## 12.2 Phase 1: OAuth + Discovery + +1. Implement well-known protected resource metadata route. +2. Implement 401 challenge behavior with resource_metadata pointer. +3. Wire Auth0 token validation with PKCE-compatible expectations. +4. Validate claim-to-user mapping against existing auth/user model. + +Deliverable: + +- end-to-end authenticated connection from Claude hosted surface. + +## 12.3 Phase 2: Read-Only Tool Registry + +1. Build curated tool registry abstraction. +2. Add first read-only tools from approved list. +3. Add strict input/output schemas and pagination bounds. +4. Add authorization policies per tool. + +Deliverable: + +- usable phase 1 feature set with read-only operations. + +## 12.4 Phase 3: Hardening + +1. Error taxonomy implementation and mapping. +2. Rate limiting and request budget controls. +3. Structured logs, metrics, trace propagation. +4. Payload truncation and partial-result pattern. + +Deliverable: + +- production-hardened connector behavior. + +## 12.5 Phase 4: Directory Readiness + +1. Complete security and reliability checklist. +2. Finalize connector metadata and documentation. +3. Perform submission dry-run and integration checks. + +Deliverable: + +- directory-ready connector package. + +## 13) Testing Strategy + +## 13.1 Unit Tests + +- Auth header parsing and token validation helpers +- Scope narrowing and role checks +- Tool input validation +- Error mapper behavior +- Output truncation behavior + +## 13.2 Integration Tests + +- OAuth discovery and 401 challenge flow +- Token exchange and authenticated tool calls +- GraphQL upstream success/error translation +- Cross-tenant access denial +- Pagination and filtering correctness + +## 13.3 Contract Tests + +- MCP protocol compliance for tool listing/invocation +- Well-known metadata document shape +- WWW-Authenticate header conformance + +## 13.4 Security Tests + +- Token misuse/replay scenarios +- Invalid audience/issuer claims +- Scope escalation attempts +- Injection and malformed input fuzzing +- Secret leakage checks in logs + +## 13.5 Performance and Reliability Tests + +- Tool latency under expected concurrent load +- Timeout and retry behavior under upstream slowness +- Auth endpoint response-time compliance + +## 13.6 End-to-End Acceptance Tests + +Must pass before production: + +- Claude hosted surface can connect and invoke tools +- Unauthorized business data is never returned +- All major failure modes return correct error classes +- Observability dashboards show complete request traces + +## 14) Environment and Configuration + +Define and validate env vars in package config, including: + +- MCP_SERVER_PORT +- MCP_PUBLIC_BASE_URL +- MCP_ENABLED +- MCP_TOOL_ALLOWLIST +- AUTH0_ISSUER_URL +- AUTH0_AUDIENCE +- AUTH0_JWKS_URL +- GRAPHQL_UPSTREAM_URL +- GRAPHQL_UPSTREAM_TIMEOUT_MS +- MCP_RATE_LIMIT_CONFIG + +Defaults: + +- secure by default +- least privilege +- explicit allowlist for tools in production + +## 15) Risks and Mitigations + +Risk: accidental overexposure of capabilities + +- Mitigation: explicit tool allowlist + no generic GraphQL execution tool + +Risk: auth discovery fragility + +- Mitigation: same-domain well-known routes + explicit 401 resource_metadata pointer + +Risk: tenant leakage + +- Mitigation: mandatory scope checks + integration tests for cross-business denial + +Risk: oversized output + +- Mitigation: strict pagination, output caps, structured partial responses + +Risk: operational blind spots + +- Mitigation: mandatory logs/metrics/traces before launch + +## 16) Open Questions (Resolve Before Coding Freeze) + +- Which exact read-only report operations are included in MVP? +- Should business scope selection be explicit tool input or inferred default only in phase 1? +- Do we need connector-level feature flags per tenant for staged rollout? +- What is the exact SLO target for tool latency? + +## 17) Done Criteria + +Implementation is considered complete when: + +- All phase 1 tools are implemented and tested +- OAuth connection works reliably on hosted Claude surfaces +- Security tests and tenant-isolation tests pass +- Observability and runbook documentation are complete +- Directory submission checklist is satisfied + +## 18) Developer Kickoff Checklist + +1. Create package skeleton in packages/mcp-server. +2. Implement OAuth discovery and 401 challenge flow. +3. Implement token validation and identity mapping. +4. Build curated read-only tool registry with strict schemas. +5. Add integration tests for auth, scope, and tool execution. +6. Add observability instrumentation. +7. Run full validation and prepare connector submission assets. From 616b1eaaac12884da8ae01d29b36941871ba7304 Mon Sep 17 00:00:00 2001 From: Gil Gardosh Date: Thu, 16 Jul 2026 15:24:09 +0300 Subject: [PATCH 02/38] docs update --- docs/mcp/implementation-blueprint.md | 7 ++++--- docs/mcp/spec.md | 15 ++++++++++++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/docs/mcp/implementation-blueprint.md b/docs/mcp/implementation-blueprint.md index 0ae3baec4..39152a21f 100644 --- a/docs/mcp/implementation-blueprint.md +++ b/docs/mcp/implementation-blueprint.md @@ -450,7 +450,7 @@ Add bearer token extraction and Auth0 JWT verification. Requirements: - Parse Authorization: Bearer header. -- Verify token using Auth0 issuer, audience, and JWKS. +- Verify token using Auth0 issuer, audience, and JWKS (reusing or adapting the existing shared auth utilities and jose setup where possible). - Enforce token expiry and signature checks. - Return typed auth principal object on success. @@ -538,14 +538,15 @@ Requirements: - Implement request function with typed query/mutation wrappers for read-only operations. - Add timeout budget and cancellation. - Retry only idempotent read failures (bounded attempts, no auth/validation retries). -- Propagate correlation id to upstream headers. +- Propagate correlation id and the authenticated user's Authorization bearer token to upstream headers. Constraints: - No generic execute-anything API exposed to tools. +- Forward Authorization only from authenticated request context, and never log or persist raw token values. - Sanitize upstream errors. Validation: -- Unit tests for timeout, retry eligibility, and correlation header propagation. +- Unit tests for timeout, retry eligibility, and upstream header propagation (correlation id and Authorization). ``` ## Prompt 13 - Tool 1: charges search/browse (read-only) diff --git a/docs/mcp/spec.md b/docs/mcp/spec.md index 9cfb7fc0e..a63efdf31 100644 --- a/docs/mcp/spec.md +++ b/docs/mcp/spec.md @@ -265,7 +265,8 @@ Phase 1 tools must avoid high-sensitivity credential domains. ## 9.5 Caching -- Allow short-lived in-process cache only for non-sensitive metadata and schema descriptors. +- Allow short-lived in-process cache (with strict TTL and maximum size limits, for example LRU + eviction, to prevent memory exhaustion) only for non-sensitive metadata and schema descriptors. - No cross-user cache key collisions. - Cache keys must include user and business scope dimensions where relevant. @@ -465,13 +466,21 @@ Define and validate env vars in package config, including: - MCP_PUBLIC_BASE_URL - MCP_ENABLED - MCP_TOOL_ALLOWLIST -- AUTH0_ISSUER_URL +- AUTH0_DOMAIN - AUTH0_AUDIENCE -- AUTH0_JWKS_URL +- AUTH0_ISSUER_URL (optional override; default derived from AUTH0_DOMAIN) +- AUTH0_JWKS_URL (optional override; default derived from AUTH0_DOMAIN) - GRAPHQL_UPSTREAM_URL - GRAPHQL_UPSTREAM_TIMEOUT_MS - MCP_RATE_LIMIT_CONFIG +Auth0 naming/mapping guidance for monorepo consistency: + +- Prefer the existing server-style auth0 shape: AUTH0_DOMAIN + AUTH0_AUDIENCE. +- Derive issuer as https:/// when AUTH0_ISSUER_URL is not set. +- Derive JWKS URL as https:///.well-known/jwks.json when AUTH0_JWKS_URL is not set. +- If explicit AUTH0_ISSUER_URL or AUTH0_JWKS_URL are provided, treat them as overrides. + Defaults: - secure by default From df5bcb5a260d43f064d0a58d9ea572d159dd640d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 16 Jul 2026 12:34:17 +0000 Subject: [PATCH 03/38] chore(dependencies): updated changesets for modified dependencies --- .changeset/@accounter_scraper-app-3941-dependencies.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/@accounter_scraper-app-3941-dependencies.md diff --git a/.changeset/@accounter_scraper-app-3941-dependencies.md b/.changeset/@accounter_scraper-app-3941-dependencies.md new file mode 100644 index 000000000..8383a0729 --- /dev/null +++ b/.changeset/@accounter_scraper-app-3941-dependencies.md @@ -0,0 +1,5 @@ +--- +"@accounter/scraper-app": patch +--- +dependencies updates: + - Updated dependency [`@fastify/static@10.1.0` ↗︎](https://www.npmjs.com/package/@fastify/static/v/10.1.0) (from `9.3.0`, in `dependencies`) From 6f64b41d0685fae89ce8ec9fb97f15426bde7fda Mon Sep 17 00:00:00 2001 From: Gil Gardosh Date: Mon, 20 Jul 2026 14:35:01 +0300 Subject: [PATCH 04/38] feat(mcp-server): scaffold package skeleton (MCP Prompt 01) (#3951) * feat(mcp-server): scaffold package skeleton (Prompt 01) Create packages/mcp-server with minimal TypeScript scaffolding and yarn workspace scripts, following the incremental MCP build plan in docs/mcp/implementation-blueprint.md. - package.json with build/dev/lint/test/typecheck scripts - tsconfig.json extending the monorepo base config - package-local vitest config for isolated test runs - src/index.ts no-op startup entrypoint - placeholder scaffold test under src/__tests__ Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo * fix(mcp-server): use fileURLToPath for direct-run detection Address review: comparing import.meta.url to a hand-built file:// string is fragile for paths with spaces/special chars (URL-encoded in import.meta.url). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo --------- Co-authored-by: Claude --- packages/mcp-server/README.md | 25 ++++++++++ packages/mcp-server/package.json | 47 +++++++++++++++++++ .../mcp-server/src/__tests__/index.test.ts | 12 +++++ packages/mcp-server/src/index.ts | 31 ++++++++++++ packages/mcp-server/tsconfig.json | 15 ++++++ packages/mcp-server/vitest.config.ts | 11 +++++ yarn.lock | 11 +++++ 7 files changed, 152 insertions(+) create mode 100644 packages/mcp-server/README.md create mode 100644 packages/mcp-server/package.json create mode 100644 packages/mcp-server/src/__tests__/index.test.ts create mode 100644 packages/mcp-server/src/index.ts create mode 100644 packages/mcp-server/tsconfig.json create mode 100644 packages/mcp-server/vitest.config.ts diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md new file mode 100644 index 000000000..f90281530 --- /dev/null +++ b/packages/mcp-server/README.md @@ -0,0 +1,25 @@ +# @accounter/mcp-server + +Remote MCP (Model Context Protocol) server that exposes a curated, **read-only** subset of Accounter +capabilities to Claude clients (Claude.ai / Claude Desktop). + +See the design docs: + +- [`docs/mcp/spec.md`](../../docs/mcp/spec.md) — connector specification +- [`docs/mcp/implementation-blueprint.md`](../../docs/mcp/implementation-blueprint.md) — incremental + implementation plan + +## Status + +Early scaffolding. This package is being built incrementally following the prompt pack in the +implementation blueprint. It currently contains only the package skeleton (no runtime behavior yet). + +## Scripts + +```bash +yarn workspace @accounter/mcp-server build # tsc → dist/ +yarn workspace @accounter/mcp-server dev # run entrypoint with tsx (watch) +yarn workspace @accounter/mcp-server lint # eslint +yarn workspace @accounter/mcp-server test # vitest (package-scoped) +yarn workspace @accounter/mcp-server typecheck # tsc --noEmit +``` diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json new file mode 100644 index 000000000..fec0accf4 --- /dev/null +++ b/packages/mcp-server/package.json @@ -0,0 +1,47 @@ +{ + "name": "@accounter/mcp-server", + "version": "0.0.0", + "type": "module", + "description": "Remote MCP (Model Context Protocol) server exposing a curated, read-only subset of Accounter capabilities to Claude clients", + "repository": { + "type": "git", + "url": "git+https://github.com/Urigo/accounter-fullstack.git", + "directory": "packages/mcp-server" + }, + "homepage": "https://github.com/Urigo/accounter-fullstack/tree/main/packages/mcp-server#readme", + "bugs": { + "url": "https://github.com/Urigo/accounter-fullstack/issues" + }, + "author": "Gil Gardosh ", + "license": "MIT", + "private": true, + "engines": { + "node": "26.5.0" + }, + "main": "dist/index.js", + "module": "dist/index.js", + "files": [ + "dist" + ], + "keywords": [ + "mcp", + "model-context-protocol", + "connector", + "accounter" + ], + "scripts": { + "build": "tsc", + "dev": "tsx watch src/index.ts", + "lint": "eslint './src/**/*.{js,ts,tsx}' --quiet", + "start": "node dist/index.js", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/node": "25.9.5", + "tsx": "4.23.1", + "typescript": "6.0.3", + "vitest": "4.1.10" + } +} diff --git a/packages/mcp-server/src/__tests__/index.test.ts b/packages/mcp-server/src/__tests__/index.test.ts new file mode 100644 index 000000000..520083345 --- /dev/null +++ b/packages/mcp-server/src/__tests__/index.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import { main, PACKAGE_NAME } from '../index.js'; + +describe('mcp-server scaffold', () => { + it('exposes the package name', () => { + expect(PACKAGE_NAME).toBe('@accounter/mcp-server'); + }); + + it('main() is a no-op and does not throw', () => { + expect(() => main()).not.toThrow(); + }); +}); diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts new file mode 100644 index 000000000..08108a595 --- /dev/null +++ b/packages/mcp-server/src/index.ts @@ -0,0 +1,31 @@ +/** + * Accounter MCP server entrypoint. + * + * Phase 1 (see docs/mcp/spec.md) exposes a curated, read-only subset of + * Accounter capabilities to Claude clients over a remote MCP (Model Context + * Protocol) endpoint. + * + * This is the scaffolding entrypoint (Prompt 01). It intentionally performs no + * runtime work yet — HTTP transport, OAuth discovery, and the tool registry are + * added in subsequent, incremental steps. Keeping this a no-op keeps the + * package compilable and importable without locking in a runtime framework. + */ + +import { fileURLToPath } from 'node:url'; + +export const PACKAGE_NAME = '@accounter/mcp-server'; + +/** + * Placeholder startup entrypoint. Later prompts replace this with the HTTP + * server bootstrap, health route, and MCP transport wiring. + */ +export function main(): void { + // No-op for now. +} + +// Only auto-run when executed directly (e.g. `node dist/index.js`), never when +// imported by tests or other modules. Compare via fileURLToPath so paths with +// spaces or other special characters (URL-encoded in import.meta.url) match. +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + main(); +} diff --git a/packages/mcp-server/tsconfig.json b/packages/mcp-server/tsconfig.json new file mode 100644 index 000000000..e306bfcac --- /dev/null +++ b/packages/mcp-server/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "module": "ES2022", + "target": "ES2022", + "strict": true, + "sourceMap": true, + "declaration": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/mcp-server/vitest.config.ts b/packages/mcp-server/vitest.config.ts new file mode 100644 index 000000000..0a2995a8d --- /dev/null +++ b/packages/mcp-server/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; + +// Package-local Vitest config so `yarn workspace @accounter/mcp-server test` +// runs only this package's tests in isolation. The repo-root config still +// discovers these files via its `unit` project glob when running `yarn test`. +export default defineConfig({ + test: { + globals: true, + include: ['src/**/*.test.ts'], + }, +}); diff --git a/yarn.lock b/yarn.lock index f23252ab2..3ba8c17ae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -264,6 +264,17 @@ __metadata: languageName: unknown linkType: soft +"@accounter/mcp-server@workspace:packages/mcp-server": + version: 0.0.0-use.local + resolution: "@accounter/mcp-server@workspace:packages/mcp-server" + dependencies: + "@types/node": "npm:25.9.5" + tsx: "npm:4.23.1" + typescript: "npm:6.0.3" + vitest: "npm:4.1.10" + languageName: unknown + linkType: soft + "@accounter/modern-poalim-scraper@workspace:^, @accounter/modern-poalim-scraper@workspace:packages/modern-poalim-scraper": version: 0.0.0-use.local resolution: "@accounter/modern-poalim-scraper@workspace:packages/modern-poalim-scraper" From ca3dd81c888eda4ae3a4b3842da676595af9f608 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 20 Jul 2026 11:37:31 +0000 Subject: [PATCH 05/38] chore(dependencies): updated changesets for modified dependencies --- .changeset/@accounter_client-3941-dependencies.md | 6 ++++++ .changeset/@accounter_scraper-app-3941-dependencies.md | 2 ++ .changeset/@accounter_server-3941-dependencies.md | 7 +++++++ 3 files changed, 15 insertions(+) create mode 100644 .changeset/@accounter_client-3941-dependencies.md create mode 100644 .changeset/@accounter_server-3941-dependencies.md diff --git a/.changeset/@accounter_client-3941-dependencies.md b/.changeset/@accounter_client-3941-dependencies.md new file mode 100644 index 000000000..1e183918c --- /dev/null +++ b/.changeset/@accounter_client-3941-dependencies.md @@ -0,0 +1,6 @@ +--- +"@accounter/client": patch +--- +dependencies updates: + - Updated dependency [`@auth0/auth0-react@2.22.0` ↗︎](https://www.npmjs.com/package/@auth0/auth0-react/v/2.22.0) (from `2.21.0`, in `dependencies`) + - Updated dependency [`lucide-react@1.25.0` ↗︎](https://www.npmjs.com/package/lucide-react/v/1.25.0) (from `1.24.0`, in `dependencies`) diff --git a/.changeset/@accounter_scraper-app-3941-dependencies.md b/.changeset/@accounter_scraper-app-3941-dependencies.md index 8383a0729..8e26f435b 100644 --- a/.changeset/@accounter_scraper-app-3941-dependencies.md +++ b/.changeset/@accounter_scraper-app-3941-dependencies.md @@ -3,3 +3,5 @@ --- dependencies updates: - Updated dependency [`@fastify/static@10.1.0` ↗︎](https://www.npmjs.com/package/@fastify/static/v/10.1.0) (from `9.3.0`, in `dependencies`) + - Updated dependency [`fast-xml-parser@5.10.1` ↗︎](https://www.npmjs.com/package/fast-xml-parser/v/5.10.1) (from `5.10.0`, in `dependencies`) + - Updated dependency [`ws@8.21.1` ↗︎](https://www.npmjs.com/package/ws/v/8.21.1) (from `8.21.0`, in `dependencies`) diff --git a/.changeset/@accounter_server-3941-dependencies.md b/.changeset/@accounter_server-3941-dependencies.md new file mode 100644 index 000000000..084a66deb --- /dev/null +++ b/.changeset/@accounter_server-3941-dependencies.md @@ -0,0 +1,7 @@ +--- +"@accounter/server": patch +--- +dependencies updates: + - Updated dependency [`@ai-sdk/anthropic@4.0.15` ↗︎](https://www.npmjs.com/package/@ai-sdk/anthropic/v/4.0.15) (from `4.0.14`, in `dependencies`) + - Updated dependency [`ai@7.0.29` ↗︎](https://www.npmjs.com/package/ai/v/7.0.29) (from `7.0.26`, in `dependencies`) + - Updated dependency [`auth0@6.0.0` ↗︎](https://www.npmjs.com/package/auth0/v/6.0.0) (from `5.14.0`, in `dependencies`) From 70e3b69957d220b351d58fba2d0de97b3e50d1eb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 20 Jul 2026 12:05:57 +0000 Subject: [PATCH 06/38] chore(dependencies): updated changesets for modified dependencies --- .changeset/@accounter_client-3941-dependencies.md | 6 ------ .changeset/@accounter_scraper-app-3941-dependencies.md | 7 ------- .changeset/@accounter_server-3941-dependencies.md | 7 ------- 3 files changed, 20 deletions(-) delete mode 100644 .changeset/@accounter_client-3941-dependencies.md delete mode 100644 .changeset/@accounter_scraper-app-3941-dependencies.md delete mode 100644 .changeset/@accounter_server-3941-dependencies.md diff --git a/.changeset/@accounter_client-3941-dependencies.md b/.changeset/@accounter_client-3941-dependencies.md deleted file mode 100644 index 1e183918c..000000000 --- a/.changeset/@accounter_client-3941-dependencies.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@accounter/client": patch ---- -dependencies updates: - - Updated dependency [`@auth0/auth0-react@2.22.0` ↗︎](https://www.npmjs.com/package/@auth0/auth0-react/v/2.22.0) (from `2.21.0`, in `dependencies`) - - Updated dependency [`lucide-react@1.25.0` ↗︎](https://www.npmjs.com/package/lucide-react/v/1.25.0) (from `1.24.0`, in `dependencies`) diff --git a/.changeset/@accounter_scraper-app-3941-dependencies.md b/.changeset/@accounter_scraper-app-3941-dependencies.md deleted file mode 100644 index 8e26f435b..000000000 --- a/.changeset/@accounter_scraper-app-3941-dependencies.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@accounter/scraper-app": patch ---- -dependencies updates: - - Updated dependency [`@fastify/static@10.1.0` ↗︎](https://www.npmjs.com/package/@fastify/static/v/10.1.0) (from `9.3.0`, in `dependencies`) - - Updated dependency [`fast-xml-parser@5.10.1` ↗︎](https://www.npmjs.com/package/fast-xml-parser/v/5.10.1) (from `5.10.0`, in `dependencies`) - - Updated dependency [`ws@8.21.1` ↗︎](https://www.npmjs.com/package/ws/v/8.21.1) (from `8.21.0`, in `dependencies`) diff --git a/.changeset/@accounter_server-3941-dependencies.md b/.changeset/@accounter_server-3941-dependencies.md deleted file mode 100644 index 084a66deb..000000000 --- a/.changeset/@accounter_server-3941-dependencies.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@accounter/server": patch ---- -dependencies updates: - - Updated dependency [`@ai-sdk/anthropic@4.0.15` ↗︎](https://www.npmjs.com/package/@ai-sdk/anthropic/v/4.0.15) (from `4.0.14`, in `dependencies`) - - Updated dependency [`ai@7.0.29` ↗︎](https://www.npmjs.com/package/ai/v/7.0.29) (from `7.0.26`, in `dependencies`) - - Updated dependency [`auth0@6.0.0` ↗︎](https://www.npmjs.com/package/auth0/v/6.0.0) (from `5.14.0`, in `dependencies`) From bb7bd345f2b3cec7c244ac7326ddcc788aa5607c Mon Sep 17 00:00:00 2001 From: Gil Gardosh Date: Mon, 20 Jul 2026 15:10:51 +0300 Subject: [PATCH 07/38] feat(mcp-server): strict environment config (MCP Prompt 02) (#3952) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mcp-server): strict environment config (Prompt 02) Add a fail-fast, schema-validated environment configuration module for the MCP server following docs/mcp/spec.md §14. - src/config/env.ts: zod schema covering MCP host/port/enabled/allowlist, Auth0 issuer/audience/JWKS, and upstream GraphQL URL/timeout, with secure defaults and least-privilege empty tool allowlist - pure, testable parseEnv() plus lazy memoized env accessor so importing the module is side-effect free while first access still fails fast - unit tests for valid config, defaults, derivations, and invalid/missing vars - env variable table documented in module comments and README Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo * fix(mcp-server): pass processEnv to dotenv so custom source is honored Address review: dotenv mutates process.env by default; when loadEnv is called with a custom source, populate that source instead. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo --------- Co-authored-by: Claude --- packages/mcp-server/README.md | 22 +- packages/mcp-server/package.json | 4 + .../src/config/__tests__/env.test.ts | 113 ++++++++++ packages/mcp-server/src/config/env.ts | 201 ++++++++++++++++++ yarn.lock | 2 + 5 files changed, 341 insertions(+), 1 deletion(-) create mode 100644 packages/mcp-server/src/config/__tests__/env.test.ts create mode 100644 packages/mcp-server/src/config/env.ts diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index f90281530..3d906bc4a 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -12,7 +12,27 @@ See the design docs: ## Status Early scaffolding. This package is being built incrementally following the prompt pack in the -implementation blueprint. It currently contains only the package skeleton (no runtime behavior yet). +implementation blueprint. It currently contains the package skeleton and strict environment +configuration (no HTTP/MCP runtime behavior yet). + +## Configuration + +Environment variables are validated at startup with a strict schema +([`src/config/env.ts`](src/config/env.ts)). Missing required variables or malformed values cause the +process to exit immediately with a clear error. Secrets are supplied via the environment only. + +| Variable | Required | Default | Description | +| ----------------------------- | -------- | ------------------- | --------------------------------------------------------------- | +| `MCP_PUBLIC_BASE_URL` | yes | — | Public HTTPS origin of this MCP server (used in OAuth metadata) | +| `AUTH0_ISSUER_URL` | yes | — | Auth0 issuer/tenant URL used to validate access tokens | +| `AUTH0_AUDIENCE` | yes | — | Expected `aud` claim for incoming access tokens | +| `GRAPHQL_UPSTREAM_URL` | yes | — | Base URL of the Accounter GraphQL server the tools call | +| `MCP_SERVER_PORT` | no | `3100` | TCP port the HTTP transport listens on | +| `MCP_ENABLED` | no | `1` | Master kill-switch (`1` on / `0` off) | +| `MCP_TOOL_ALLOWLIST` | no | `''` (none) | Comma-separated tool names allowed (empty = least privilege) | +| `AUTH0_JWKS_URL` | no | derived from issuer | JWKS endpoint; defaults to `/.well-known/jwks.json` | +| `GRAPHQL_UPSTREAM_TIMEOUT_MS` | no | `10000` | Upstream GraphQL request timeout budget (ms) | +| `MCP_RATE_LIMIT_CONFIG` | no | `''` (defaults) | Optional rate-limit override spec (parsed by the limiter later) | ## Scripts diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index fec0accf4..fe2d4d835 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -38,6 +38,10 @@ "test:watch": "vitest", "typecheck": "tsc --noEmit" }, + "dependencies": { + "dotenv": "17.4.2", + "zod": "4.4.3" + }, "devDependencies": { "@types/node": "25.9.5", "tsx": "4.23.1", diff --git a/packages/mcp-server/src/config/__tests__/env.test.ts b/packages/mcp-server/src/config/__tests__/env.test.ts new file mode 100644 index 000000000..79f4bb24f --- /dev/null +++ b/packages/mcp-server/src/config/__tests__/env.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest'; +import { EnvValidationError, parseEnv } from '../env.js'; + +const validEnv: NodeJS.ProcessEnv = { + MCP_PUBLIC_BASE_URL: 'https://mcp.example.com', + AUTH0_ISSUER_URL: 'https://tenant.auth0.com/', + AUTH0_AUDIENCE: 'https://api.accounter.example', + GRAPHQL_UPSTREAM_URL: 'http://localhost:4000/graphql', +}; + +describe('parseEnv — valid configuration', () => { + it('parses required vars and applies secure defaults', () => { + const config = parseEnv(validEnv); + + expect(config.server.port).toBe(3100); + expect(config.server.enabled).toBe(true); + expect(config.server.toolAllowlist).toEqual([]); + expect(config.auth0.issuerUrl).toBe('https://tenant.auth0.com/'); + expect(config.auth0.audience).toBe('https://api.accounter.example'); + expect(config.upstream.graphqlUrl).toBe('http://localhost:4000/graphql'); + expect(config.upstream.timeoutMs).toBe(10_000); + expect(config.rateLimit.raw).toBe(''); + }); + + it('strips the trailing slash from the public base url', () => { + const config = parseEnv({ ...validEnv, MCP_PUBLIC_BASE_URL: 'https://mcp.example.com/' }); + expect(config.server.publicBaseUrl).toBe('https://mcp.example.com'); + }); + + it('derives the JWKS url from the issuer when not provided', () => { + const config = parseEnv(validEnv); + expect(config.auth0.jwksUrl).toBe('https://tenant.auth0.com/.well-known/jwks.json'); + }); + + it('honors an explicit JWKS url override', () => { + const config = parseEnv({ + ...validEnv, + AUTH0_JWKS_URL: 'https://tenant.auth0.com/custom/jwks.json', + }); + expect(config.auth0.jwksUrl).toBe('https://tenant.auth0.com/custom/jwks.json'); + }); + + it('coerces numeric vars and parses the tool allowlist', () => { + const config = parseEnv({ + ...validEnv, + MCP_SERVER_PORT: '8080', + GRAPHQL_UPSTREAM_TIMEOUT_MS: '2500', + MCP_TOOL_ALLOWLIST: 'charges_search, tags_lookup ,', + }); + expect(config.server.port).toBe(8080); + expect(config.upstream.timeoutMs).toBe(2500); + expect(config.server.toolAllowlist).toEqual(['charges_search', 'tags_lookup']); + }); + + it('treats empty strings as unset and falls back to defaults', () => { + const config = parseEnv({ + ...validEnv, + MCP_SERVER_PORT: '', + MCP_ENABLED: '', + MCP_TOOL_ALLOWLIST: '', + }); + expect(config.server.port).toBe(3100); + expect(config.server.enabled).toBe(true); + expect(config.server.toolAllowlist).toEqual([]); + }); + + it('supports disabling the server via MCP_ENABLED=0', () => { + const config = parseEnv({ ...validEnv, MCP_ENABLED: '0' }); + expect(config.server.enabled).toBe(false); + }); +}); + +describe('parseEnv — invalid configuration', () => { + it('throws when a required var is missing', () => { + const { MCP_PUBLIC_BASE_URL: _omitted, ...rest } = validEnv; + expect(() => parseEnv(rest)).toThrow(EnvValidationError); + }); + + it('reports every missing required var in the error', () => { + try { + parseEnv({}); + expect.unreachable('expected parseEnv to throw'); + } catch (error) { + expect(error).toBeInstanceOf(EnvValidationError); + const report = (error as EnvValidationError).report; + expect(report).toContain('MCP_PUBLIC_BASE_URL'); + expect(report).toContain('AUTH0_ISSUER_URL'); + expect(report).toContain('AUTH0_AUDIENCE'); + expect(report).toContain('GRAPHQL_UPSTREAM_URL'); + } + }); + + it('rejects a malformed URL', () => { + expect(() => parseEnv({ ...validEnv, GRAPHQL_UPSTREAM_URL: 'not-a-url' })).toThrow( + EnvValidationError, + ); + }); + + it('rejects a non-numeric port', () => { + expect(() => parseEnv({ ...validEnv, MCP_SERVER_PORT: 'abc' })).toThrow(EnvValidationError); + }); + + it('rejects an out-of-range port', () => { + expect(() => parseEnv({ ...validEnv, MCP_SERVER_PORT: '70000' })).toThrow(EnvValidationError); + }); + + it('rejects an empty audience', () => { + expect(() => parseEnv({ ...validEnv, AUTH0_AUDIENCE: ' ' })).not.toThrow(); + // whitespace is a non-empty string at the schema level; emptiness is only + // enforced against the literal empty string. + expect(() => parseEnv({ ...validEnv, AUTH0_AUDIENCE: '' })).toThrow(EnvValidationError); + }); +}); diff --git a/packages/mcp-server/src/config/env.ts b/packages/mcp-server/src/config/env.ts new file mode 100644 index 000000000..2b7261e1c --- /dev/null +++ b/packages/mcp-server/src/config/env.ts @@ -0,0 +1,201 @@ +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { config as dotenv } from 'dotenv'; +import zod from 'zod'; + +/** + * Environment configuration for the Accounter MCP server. + * + * All configuration is validated at startup with a strict schema. Missing + * required variables or malformed values cause the process to exit immediately + * with a clear, actionable error (fail-fast) rather than failing later at + * request time. + * + * | Variable | Required | Default | Description | + * | --------------------------- | -------- | ------------------------ | ------------------------------------------------------------------ | + * | MCP_PUBLIC_BASE_URL | yes | — | Public HTTPS origin of this MCP server (used in OAuth metadata). | + * | AUTH0_ISSUER_URL | yes | — | Auth0 issuer/tenant URL used to validate access tokens. | + * | AUTH0_AUDIENCE | yes | — | Expected `aud` claim for incoming access tokens. | + * | GRAPHQL_UPSTREAM_URL | yes | — | Base URL of the Accounter GraphQL server the tools call. | + * | MCP_SERVER_PORT | no | 3100 | TCP port the HTTP transport listens on. | + * | MCP_ENABLED | no | 1 | Master kill-switch (`1` on / `0` off). | + * | MCP_TOOL_ALLOWLIST | no | '' (none) | Comma-separated tool names allowed in production (least priv). | + * | AUTH0_JWKS_URL | no | derived from issuer | JWKS endpoint; defaults to `/.well-known/jwks.json`. | + * | GRAPHQL_UPSTREAM_TIMEOUT_MS | no | 10000 | Upstream GraphQL request timeout budget in milliseconds. | + * | MCP_RATE_LIMIT_CONFIG | no | '' (defaults applied) | Optional rate-limit override spec (parsed by the limiter later). | + * + * Secrets are never embedded here; they are supplied via the environment only. + */ + +/** Treat an empty string (`''`) as `undefined` so defaults apply cleanly. */ +const emptyStringAsUndefined = (input: T) => + zod.preprocess((value: unknown) => (value === '' ? undefined : value), input); + +const booleanFlag = (defaultValue: '0' | '1') => + emptyStringAsUndefined( + zod + .union([zod.literal('1'), zod.literal('0')]) + .optional() + .default(defaultValue), + ); + +/** + * Strict schema for the raw process environment. `.strict()` is intentionally + * NOT used here because the ambient environment contains many unrelated + * variables; instead we only read the keys we know about. + */ +export const envSchema = zod.object({ + // --- Required (no safe default) --- + MCP_PUBLIC_BASE_URL: zod.url({ error: 'MCP_PUBLIC_BASE_URL must be a valid URL' }), + AUTH0_ISSUER_URL: zod.url({ error: 'AUTH0_ISSUER_URL must be a valid URL' }), + AUTH0_AUDIENCE: zod.string().min(1, { error: 'AUTH0_AUDIENCE must be a non-empty string' }), + GRAPHQL_UPSTREAM_URL: zod.url({ error: 'GRAPHQL_UPSTREAM_URL must be a valid URL' }), + + // --- Optional with secure defaults --- + MCP_SERVER_PORT: emptyStringAsUndefined( + zod.coerce.number().int().positive().max(65_535).optional().default(3100), + ), + MCP_ENABLED: booleanFlag('1'), + // Least-privilege default: empty allowlist means no tools are exposed unless + // explicitly enabled. The registry enforces this in later prompts. + MCP_TOOL_ALLOWLIST: emptyStringAsUndefined(zod.string().optional().default('')), + AUTH0_JWKS_URL: emptyStringAsUndefined( + zod.url({ error: 'AUTH0_JWKS_URL must be a valid URL' }).optional(), + ), + GRAPHQL_UPSTREAM_TIMEOUT_MS: emptyStringAsUndefined( + zod.coerce.number().int().positive().max(120_000).optional().default(10_000), + ), + MCP_RATE_LIMIT_CONFIG: emptyStringAsUndefined(zod.string().optional().default('')), +}); + +export type RawEnv = zod.infer; + +/** Typed, normalized configuration object consumed by the rest of the server. */ +export interface AppConfig { + server: { + port: number; + /** Public origin without a trailing slash. */ + publicBaseUrl: string; + enabled: boolean; + /** Parsed tool allowlist; empty array means "no tools exposed". */ + toolAllowlist: readonly string[]; + }; + auth0: { + issuerUrl: string; + audience: string; + jwksUrl: string; + }; + upstream: { + graphqlUrl: string; + timeoutMs: number; + }; + rateLimit: { + /** Raw config spec; parsed by the rate limiter in a later prompt. */ + raw: string; + }; +} + +/** Thrown when environment validation fails. Carries a human-readable report. */ +export class EnvValidationError extends Error { + constructor(public readonly report: string) { + super(`Invalid environment configuration:\n${report}`); + this.name = 'EnvValidationError'; + } +} + +const stripTrailingSlash = (url: string): string => url.replace(/\/+$/, ''); + +const deriveJwksUrl = (issuerUrl: string): string => + new URL('.well-known/jwks.json', `${stripTrailingSlash(issuerUrl)}/`).toString(); + +/** + * Validate a raw environment source and build the typed config. Throws + * {@link EnvValidationError} on any validation failure. Pure and side-effect + * free, so it is directly unit-testable. + */ +export function parseEnv(source: NodeJS.ProcessEnv): AppConfig { + const result = envSchema.safeParse(source); + if (!result.success) { + throw new EnvValidationError(JSON.stringify(zod.treeifyError(result.error), null, 2)); + } + + const raw = result.data; + return { + server: { + port: raw.MCP_SERVER_PORT, + publicBaseUrl: stripTrailingSlash(raw.MCP_PUBLIC_BASE_URL), + enabled: raw.MCP_ENABLED === '1', + toolAllowlist: raw.MCP_TOOL_ALLOWLIST.split(',') + .map(name => name.trim()) + .filter(Boolean), + }, + auth0: { + issuerUrl: raw.AUTH0_ISSUER_URL, + audience: raw.AUTH0_AUDIENCE, + jwksUrl: raw.AUTH0_JWKS_URL ?? deriveJwksUrl(raw.AUTH0_ISSUER_URL), + }, + upstream: { + graphqlUrl: raw.GRAPHQL_UPSTREAM_URL, + timeoutMs: raw.GRAPHQL_UPSTREAM_TIMEOUT_MS, + }, + rateLimit: { + raw: raw.MCP_RATE_LIMIT_CONFIG, + }, + }; +} + +/** + * Load `.env` (package-root relative, or `TEST_ENV_FILE` when set) and validate. + * On failure, prints a clear report and exits the process (fail-fast startup). + */ +export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppConfig { + const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); + dotenv({ + path: + process.env.TEST_ENV_FILE && process.env.TEST_ENV_FILE.trim() !== '' + ? process.env.TEST_ENV_FILE + : [resolve(packageRoot, '.env')], + // Only surface dotenv's own debug noise outside of release builds. + debug: process.env.RELEASE ? false : undefined, + // Populate the caller-provided source (defaults to process.env) rather than + // always mutating process.env, so a custom source is honored end to end. + processEnv: source, + }); + + try { + return parseEnv(source); + } catch (error) { + if (error instanceof EnvValidationError) { + // eslint-disable-next-line no-console + console.error('[env] Invalid environment variables:\n' + error.report); + process.exit(1); + } + throw error; + } +} + +let cachedConfig: AppConfig | undefined; + +/** + * Lazily load and memoize the validated configuration. Deferring the load keeps + * merely importing this module side-effect free (so unit tests can import the + * pure helpers without triggering fail-fast `process.exit`), while the first + * real access still fails fast on a bad environment. + */ +export function getEnv(): AppConfig { + cachedConfig ??= loadEnv(); + return cachedConfig; +} + +/** Test-only hook to reset the memoized config. */ +export function resetEnvCache(): void { + cachedConfig = undefined; +} + +/** + * Validated, typed configuration for this process. Access is lazy: the + * environment is loaded and validated on first property read. + */ +export const env: AppConfig = new Proxy({} as AppConfig, { + get: (_target, property: keyof AppConfig) => getEnv()[property], +}) as AppConfig; diff --git a/yarn.lock b/yarn.lock index 3ba8c17ae..123cc4ee4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -269,9 +269,11 @@ __metadata: resolution: "@accounter/mcp-server@workspace:packages/mcp-server" dependencies: "@types/node": "npm:25.9.5" + dotenv: "npm:17.4.2" tsx: "npm:4.23.1" typescript: "npm:6.0.3" vitest: "npm:4.1.10" + zod: "npm:4.4.3" languageName: unknown linkType: soft From f9de816174534d3158b62335887a09e8578ace8b Mon Sep 17 00:00:00 2001 From: Gil Gardosh Date: Mon, 20 Jul 2026 15:43:19 +0300 Subject: [PATCH 08/38] feat(mcp-server): HTTP bootstrap with health & graceful shutdown (MCP Prompt 03) (#3953) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mcp-server): HTTP bootstrap with health and graceful shutdown (Prompt 03) Stand up a minimal node:http server with a /health endpoint, graceful shutdown, and top-level error handling — no MCP protocol logic yet. - src/server.ts: stdlib HTTP server, GET /health returning status/service/version/uptime, deterministic 404/500 routing, and a unit-testable createShutdownHandler for SIGINT/SIGTERM with a forced-exit grace period - src/logger.ts: minimal single-line JSON structured logger - src/index.ts: entrypoint installing uncaughtException/unhandledRejection handlers and starting the server with fatal-startup guarding - tests for health, routing, version, and shutdown behavior Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo * fix(mcp-server): harden health version read, shutdown, and logger Address review comments: - cache the package.json version read (avoid sync disk I/O per /health request) - close idle keep-alive connections during shutdown so it doesn't stall - wrap logger serialization in try/catch (used inside global error handlers) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo --------- Co-authored-by: Claude --- packages/mcp-server/README.md | 17 +- .../mcp-server/src/__tests__/index.test.ts | 12 +- .../mcp-server/src/__tests__/logger.test.ts | 35 ++++ .../mcp-server/src/__tests__/server.test.ts | 157 +++++++++++++++++ packages/mcp-server/src/index.ts | 35 +++- packages/mcp-server/src/logger.ts | 48 +++++ packages/mcp-server/src/server.ts | 166 ++++++++++++++++++ 7 files changed, 457 insertions(+), 13 deletions(-) create mode 100644 packages/mcp-server/src/__tests__/logger.test.ts create mode 100644 packages/mcp-server/src/__tests__/server.test.ts create mode 100644 packages/mcp-server/src/logger.ts create mode 100644 packages/mcp-server/src/server.ts diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 3d906bc4a..9cc1e118b 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -12,8 +12,21 @@ See the design docs: ## Status Early scaffolding. This package is being built incrementally following the prompt pack in the -implementation blueprint. It currently contains the package skeleton and strict environment -configuration (no HTTP/MCP runtime behavior yet). +implementation blueprint. It currently contains the package skeleton, strict environment +configuration, and a minimal HTTP server with a `/health` endpoint and graceful shutdown (no MCP +protocol logic yet). + +## Running locally + +```bash +# with required env vars set (see Configuration below): +yarn workspace @accounter/mcp-server dev +curl http://localhost:3100/health +# → {"status":"ok","service":"@accounter/mcp-server","version":"…","uptimeSeconds":…} +``` + +The server handles `SIGINT`/`SIGTERM` by closing connections and exiting cleanly (forcing exit after +a grace period). ## Configuration diff --git a/packages/mcp-server/src/__tests__/index.test.ts b/packages/mcp-server/src/__tests__/index.test.ts index 520083345..4d395565d 100644 --- a/packages/mcp-server/src/__tests__/index.test.ts +++ b/packages/mcp-server/src/__tests__/index.test.ts @@ -1,12 +1,16 @@ import { describe, expect, it } from 'vitest'; -import { main, PACKAGE_NAME } from '../index.js'; +import { installProcessErrorHandlers, main, PACKAGE_NAME, start } from '../index.js'; -describe('mcp-server scaffold', () => { +describe('mcp-server entrypoint', () => { it('exposes the package name', () => { expect(PACKAGE_NAME).toBe('@accounter/mcp-server'); }); - it('main() is a no-op and does not throw', () => { - expect(() => main()).not.toThrow(); + it('exports the startup surface', () => { + // `main`/`start` bind a port and are covered via server tests; here we only + // assert the entrypoint surface is wired up. + expect(typeof main).toBe('function'); + expect(typeof start).toBe('function'); + expect(typeof installProcessErrorHandlers).toBe('function'); }); }); diff --git a/packages/mcp-server/src/__tests__/logger.test.ts b/packages/mcp-server/src/__tests__/logger.test.ts new file mode 100644 index 000000000..ddc6a0e1a --- /dev/null +++ b/packages/mcp-server/src/__tests__/logger.test.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { log } from '../logger.js'; + +describe('log', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('writes a single-line JSON entry to console.log for non-error levels', () => { + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + log('info', 'hello', { a: 1 }); + const entry = JSON.parse(spy.mock.calls[0][0] as string); + expect(entry).toMatchObject({ level: 'info', message: 'hello', a: 1 }); + expect(typeof entry.timestamp).toBe('string'); + }); + + it('routes error level to console.error', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + log('error', 'boom'); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('does not throw on non-serializable fields, emitting a safe fallback', () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'log').mockImplementation(() => {}); + + const circular: Record = {}; + circular.self = circular; + + expect(() => log('info', 'with circular', { circular })).not.toThrow(); + const fallback = JSON.parse(errorSpy.mock.calls[0][0] as string); + expect(fallback.message).toBe('failed to serialize log entry'); + expect(fallback.originalMessage).toBe('with circular'); + }); +}); diff --git a/packages/mcp-server/src/__tests__/server.test.ts b/packages/mcp-server/src/__tests__/server.test.ts new file mode 100644 index 000000000..829fb690e --- /dev/null +++ b/packages/mcp-server/src/__tests__/server.test.ts @@ -0,0 +1,157 @@ +import type { IncomingMessage, Server, ServerResponse } from 'node:http'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Silence structured log output during server tests. +vi.spyOn(console, 'log').mockImplementation(() => {}); +vi.spyOn(console, 'error').mockImplementation(() => {}); + +const { + routes, + sendJson, + requestHandler, + getServiceVersion, + createShutdownHandler, + SERVICE_NAME, +} = await import('../server.js'); + +function mockRes(): ServerResponse & { + writeHead: ReturnType; + end: ReturnType; +} { + return { + writeHead: vi.fn(), + end: vi.fn(), + setHeader: vi.fn(), + headersSent: false, + } as unknown as ServerResponse & { + writeHead: ReturnType; + end: ReturnType; + }; +} + +function mockReq(overrides: Partial = {}): IncomingMessage { + return { headers: {}, method: 'GET', url: '/', ...overrides } as unknown as IncomingMessage; +} + +describe('sendJson', () => { + it('writes status code and JSON body', () => { + const res = mockRes(); + sendJson(res, 201, { id: 'abc' }); + expect(res.writeHead).toHaveBeenCalledWith(201, { 'Content-Type': 'application/json' }); + expect(res.end).toHaveBeenCalledWith('{"id":"abc"}'); + }); +}); + +describe('getServiceVersion', () => { + it('returns the package version', () => { + expect(getServiceVersion()).toMatch(/^\d+\.\d+\.\d+/); + }); +}); + +describe('GET /health', () => { + it('returns 200 with status, service, and version', async () => { + const res = mockRes(); + await requestHandler(mockReq({ method: 'GET', url: '/health' }), res); + + expect(res.writeHead).toHaveBeenCalledWith(200, { 'Content-Type': 'application/json' }); + const body = JSON.parse(res.end.mock.calls[0][0] as string); + expect(body.status).toBe('ok'); + expect(body.service).toBe(SERVICE_NAME); + expect(body.version).toMatch(/^\d+\.\d+\.\d+/); + expect(typeof body.uptimeSeconds).toBe('number'); + }); + + it('is registered under GET routes', () => { + expect(typeof routes.GET['/health']).toBe('function'); + }); +}); + +describe('requestHandler routing', () => { + it('returns 404 for unknown paths', async () => { + const res = mockRes(); + await requestHandler(mockReq({ method: 'GET', url: '/nope' }), res); + expect(res.writeHead).toHaveBeenCalledWith(404, { 'Content-Type': 'application/json' }); + expect(res.end).toHaveBeenCalledWith('{"error":"Not found"}'); + }); + + it('returns 404 for unsupported methods on a known path', async () => { + const res = mockRes(); + await requestHandler(mockReq({ method: 'POST', url: '/health' }), res); + expect(res.writeHead).toHaveBeenCalledWith(404, { 'Content-Type': 'application/json' }); + }); + + it('returns 500 when a handler throws', async () => { + const res = mockRes(); + const throwingReq = mockReq({ method: 'GET', url: '/health' }); + // Force sendJson's first write to throw by making writeHead throw once. + (res.writeHead as ReturnType) + .mockImplementationOnce(() => { + throw new Error('boom'); + }) + .mockImplementation(() => {}); + await requestHandler(throwingReq, res); + // Second writeHead call is the 500 fallback. + expect(res.writeHead).toHaveBeenLastCalledWith(500, { 'Content-Type': 'application/json' }); + }); +}); + +describe('createShutdownHandler', () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + it('closes the server and exits 0 on clean shutdown', () => { + const exit = vi.fn(); + const server = { + close: vi.fn((cb: (err?: Error) => void) => cb()), + } as unknown as Server; + + const handler = createShutdownHandler({ server, exit, graceMs: 1000 }); + handler('SIGTERM'); + + expect(server.close).toHaveBeenCalledTimes(1); + expect(exit).toHaveBeenCalledWith(0); + }); + + it('closes idle keep-alive connections to avoid stalling shutdown', () => { + const exit = vi.fn(); + const closeIdleConnections = vi.fn(); + const server = { + close: vi.fn((cb: (err?: Error) => void) => cb()), + closeIdleConnections, + } as unknown as Server; + + const handler = createShutdownHandler({ server, exit, graceMs: 1000 }); + handler('SIGTERM'); + + expect(closeIdleConnections).toHaveBeenCalledTimes(1); + }); + + it('exits 1 when close reports an error', () => { + const exit = vi.fn(); + const server = { + close: vi.fn((cb: (err?: Error) => void) => cb(new Error('close failed'))), + } as unknown as Server; + + const handler = createShutdownHandler({ server, exit, graceMs: 1000 }); + handler('SIGINT'); + + expect(exit).toHaveBeenCalledWith(1); + }); + + it('ignores repeated signals (idempotent)', () => { + const exit = vi.fn(); + const close = vi.fn((_cb: (err?: Error) => void) => { + /* never calls back */ + }); + const server = { close } as unknown as Server; + + const handler = createShutdownHandler({ server, exit, graceMs: 1000 }); + handler('SIGTERM'); + handler('SIGTERM'); + + expect(close).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index 08108a595..73129271e 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -5,22 +5,43 @@ * Accounter capabilities to Claude clients over a remote MCP (Model Context * Protocol) endpoint. * - * This is the scaffolding entrypoint (Prompt 01). It intentionally performs no - * runtime work yet — HTTP transport, OAuth discovery, and the tool registry are - * added in subsequent, incremental steps. Keeping this a no-op keeps the - * package compilable and importable without locking in a runtime framework. + * This module wires up process-level concerns (top-level error handling) and + * starts the HTTP server. The transport, OAuth discovery, and tool registry are + * added in subsequent, incremental steps. */ import { fileURLToPath } from 'node:url'; +import { log } from './logger.js'; +import { start } from './server.js'; export const PACKAGE_NAME = '@accounter/mcp-server'; +export { start } from './server.js'; + /** - * Placeholder startup entrypoint. Later prompts replace this with the HTTP - * server bootstrap, health route, and MCP transport wiring. + * Install last-resort handlers so an unexpected error is logged in structured + * form before the process exits, rather than crashing with a bare stack trace. */ +export function installProcessErrorHandlers(): void { + process.on('uncaughtException', error => { + log('error', 'uncaught exception', { error: String(error) }); + process.exit(1); + }); + process.on('unhandledRejection', reason => { + log('error', 'unhandled promise rejection', { reason: String(reason) }); + process.exit(1); + }); +} + +/** Startup entrypoint: install error handlers and start the HTTP server. */ export function main(): void { - // No-op for now. + installProcessErrorHandlers(); + try { + start(); + } catch (error) { + log('error', 'fatal startup error', { error: String(error) }); + process.exit(1); + } } // Only auto-run when executed directly (e.g. `node dist/index.js`), never when diff --git a/packages/mcp-server/src/logger.ts b/packages/mcp-server/src/logger.ts new file mode 100644 index 000000000..f7d5656a1 --- /dev/null +++ b/packages/mcp-server/src/logger.ts @@ -0,0 +1,48 @@ +/* eslint-disable no-console */ + +/** + * Minimal structured logger for the MCP server. + * + * Emits single-line JSON so logs are machine-parseable in production. This is a + * deliberately small foundation; request-context propagation (correlation ids, + * per-request child loggers) is layered on in a later step. Secrets and + * authorization headers must never be passed as fields. + */ + +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +export interface LogEntry { + timestamp: string; + level: LogLevel; + message: string; + [key: string]: unknown; +} + +export function log(level: LogLevel, message: string, fields?: Record): void { + const entry: LogEntry = { + ...fields, + timestamp: new Date().toISOString(), + level, + message, + }; + try { + const line = JSON.stringify(entry); + if (level === 'error') { + console.error(line); + } else { + console.log(line); + } + } catch (error) { + // Never let a non-serializable field (circular ref, BigInt, …) crash the + // process — this logger runs inside global error handlers. + console.error( + JSON.stringify({ + timestamp: new Date().toISOString(), + level: 'error', + message: 'failed to serialize log entry', + originalMessage: message, + error: String(error), + }), + ); + } +} diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts new file mode 100644 index 000000000..27076267c --- /dev/null +++ b/packages/mcp-server/src/server.ts @@ -0,0 +1,166 @@ +import { readFileSync } from 'node:fs'; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { env } from './config/env.js'; +import { log } from './logger.js'; + +/** + * HTTP server bootstrap for the MCP server. + * + * Provides a plain `node:http` server with a `/health` endpoint and graceful + * shutdown. No MCP protocol logic lives here yet — the MCP transport route is + * added in a later step. Kept dependency-free (stdlib only) to avoid runtime + * framework lock-in. + */ + +export const SERVICE_NAME = '@accounter/mcp-server'; + +let cachedVersion: string | undefined; + +/** + * Resolve the package version at runtime without importing outside `rootDir`. + * The result is cached: the version cannot change while the process runs, so + * we avoid a synchronous disk read on every `/health` request. + */ +export function getServiceVersion(): string { + if (cachedVersion !== undefined) { + return cachedVersion; + } + try { + const packageJsonPath = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'); + const parsed = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { version?: string }; + cachedVersion = parsed.version ?? '0.0.0'; + } catch { + cachedVersion = '0.0.0'; + } + return cachedVersion; +} + +export interface HealthBody { + status: 'ok'; + service: string; + version: string; + uptimeSeconds: number; +} + +export function sendJson(res: ServerResponse, statusCode: number, body: unknown): void { + res.writeHead(statusCode, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); +} + +type RouteHandler = (req: IncomingMessage, res: ServerResponse) => void | Promise; + +export const routes: Record> = { + GET: { + '/health': (_req, res) => { + const body: HealthBody = { + status: 'ok', + service: SERVICE_NAME, + version: getServiceVersion(), + uptimeSeconds: Math.round(process.uptime()), + }; + sendJson(res, 200, body); + }, + }, +}; + +export async function requestHandler(req: IncomingMessage, res: ServerResponse): Promise { + // Only the pathname matters for routing; the host is a placeholder so we never + // need to read config here. + const url = new URL(req.url ?? '/', 'http://localhost'); + try { + const handler = routes[req.method ?? '']?.[url.pathname]; + if (handler) { + await handler(req, res); + } else { + sendJson(res, 404, { error: 'Not found' }); + } + } catch (error) { + log('error', 'unhandled request error', { error: String(error), path: url.pathname }); + if (!res.headersSent) { + sendJson(res, 500, { error: 'Internal server error' }); + } + } +} + +export function createHttpServer(): Server { + return createServer(requestHandler); +} + +/** Milliseconds to wait for in-flight requests before forcing exit. */ +export const SHUTDOWN_GRACE_MS = 10_000; + +export interface ShutdownDeps { + server: Server; + exit?: (code: number) => void; + graceMs?: number; +} + +/** + * Build a signal handler that closes the server, then exits. Extracted as a + * factory so shutdown behavior is unit-testable without real signals. + */ +export function createShutdownHandler({ + server, + exit = code => process.exit(code), + graceMs = SHUTDOWN_GRACE_MS, +}: ShutdownDeps): (signal: string) => void { + let shuttingDown = false; + return (signal: string) => { + if (shuttingDown) { + return; + } + shuttingDown = true; + log('info', 'shutdown signal received', { signal }); + + const forceTimer = setTimeout(() => { + log('warn', 'graceful shutdown timed out, forcing exit', { graceMs }); + exit(1); + }, graceMs); + forceTimer.unref?.(); + + server.close(error => { + clearTimeout(forceTimer); + if (error) { + log('error', 'error during shutdown', { error: String(error) }); + exit(1); + return; + } + log('info', 'shutdown complete', { signal }); + exit(0); + }); + + // `server.close()` stops accepting new connections but leaves idle + // keep-alive sockets open, which can stall shutdown until the grace timer + // fires. Close idle connections immediately so shutdown completes promptly. + server.closeIdleConnections?.(); + }; +} + +/** + * Start the HTTP server: validate config (via the imported `env`), bind the + * port, and register graceful-shutdown signal handlers. + */ +export function start(): Server { + if (!env.server.enabled) { + log('warn', 'MCP_ENABLED=0 — server is starting in disabled mode (health only)'); + } + + const server = createHttpServer(); + const shutdown = createShutdownHandler({ server }); + + process.once('SIGINT', () => shutdown('SIGINT')); + process.once('SIGTERM', () => shutdown('SIGTERM')); + + server.listen(env.server.port, () => { + log('info', 'mcp server started', { + service: SERVICE_NAME, + version: getServiceVersion(), + port: env.server.port, + enabled: env.server.enabled, + }); + }); + + return server; +} From 37865a25dc2a8081b4f321db579f56a72cba2d7f Mon Sep 17 00:00:00 2001 From: Gil Gardosh Date: Mon, 20 Jul 2026 15:55:06 +0300 Subject: [PATCH 09/38] feat(mcp-server): MCP transport route with list-tools stub (MCP Prompt 04) (#3954) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mcp-server): MCP transport route with list-tools stub (Prompt 04) Add the Streamable HTTP MCP endpoint (POST /mcp) speaking JSON-RPC 2.0, with a request-dispatch skeleton and one internal smoke tool. Auth-agnostic and no upstream GraphQL calls yet. - src/mcp/jsonrpc.ts: minimal JSON-RPC 2.0 primitives, error codes, request validation - src/mcp/tools.ts: internal-only accounter_smoke_ping descriptor + runner - src/mcp/handler.ts: method dispatch (initialize, ping, tools/list, tools/call), deterministic method-not-found for unknown ops, bounded body reader, and the HTTP adapter (202 for notifications, 413 over cap) - src/version.ts: shared service name/version helper (avoids server↔handler import cycle) - server.ts: register POST /mcp and a deterministic 405 for GET /mcp - tests covering dispatch, malformed input, and the HTTP adapter Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo * fix(mcp-server): harden MCP transport per review Address review comments: - tools/call with an unknown tool returns InvalidParams (-32602), not MethodNotFound — the method itself is supported - validate JSON-RPC params is a structured value (object/array), never a primitive - on oversized body, pause() the stream (not destroy) and set Connection:close + destroy after finish, so the 413 response is actually delivered - cache the package.json version read in version.ts (avoid sync disk I/O per /health request) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo --------- Co-authored-by: Claude --- packages/mcp-server/README.md | 17 +- .../src/mcp/__tests__/handler.test.ts | 129 ++++++++++++++ .../src/mcp/__tests__/jsonrpc.test.ts | 71 ++++++++ packages/mcp-server/src/mcp/handler.ts | 163 ++++++++++++++++++ packages/mcp-server/src/mcp/jsonrpc.ts | 96 +++++++++++ packages/mcp-server/src/mcp/tools.ts | 70 ++++++++ packages/mcp-server/src/server.ts | 46 ++--- packages/mcp-server/src/version.ts | 29 ++++ 8 files changed, 590 insertions(+), 31 deletions(-) create mode 100644 packages/mcp-server/src/mcp/__tests__/handler.test.ts create mode 100644 packages/mcp-server/src/mcp/__tests__/jsonrpc.test.ts create mode 100644 packages/mcp-server/src/mcp/handler.ts create mode 100644 packages/mcp-server/src/mcp/jsonrpc.ts create mode 100644 packages/mcp-server/src/mcp/tools.ts create mode 100644 packages/mcp-server/src/version.ts diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 9cc1e118b..2a36ebcfe 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -13,8 +13,9 @@ See the design docs: Early scaffolding. This package is being built incrementally following the prompt pack in the implementation blueprint. It currently contains the package skeleton, strict environment -configuration, and a minimal HTTP server with a `/health` endpoint and graceful shutdown (no MCP -protocol logic yet). +configuration, a minimal HTTP server with a `/health` endpoint and graceful shutdown, and an MCP +transport route (`POST /mcp`) that speaks JSON-RPC 2.0 and lists an internal smoke tool. +Authentication, authorization, and production tools are not implemented yet. ## Running locally @@ -28,6 +29,18 @@ curl http://localhost:3100/health The server handles `SIGINT`/`SIGTERM` by closing connections and exiting cleanly (forcing exit after a grace period). +### MCP endpoint + +The transport lives at `POST /mcp` and accepts JSON-RPC 2.0. It is currently **auth-agnostic** (the +OAuth challenge is added in a later step). Supported methods: `initialize`, `ping`, `tools/list`, +and `tools/call` (for the internal `accounter_smoke_ping` tool). Unknown methods return a +deterministic JSON-RPC `-32601` error; notifications receive `202 Accepted` with no body. + +```bash +curl -sX POST http://localhost:3100/mcp -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' +``` + ## Configuration Environment variables are validated at startup with a strict schema diff --git a/packages/mcp-server/src/mcp/__tests__/handler.test.ts b/packages/mcp-server/src/mcp/__tests__/handler.test.ts new file mode 100644 index 000000000..11b326e57 --- /dev/null +++ b/packages/mcp-server/src/mcp/__tests__/handler.test.ts @@ -0,0 +1,129 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { Readable } from 'node:stream'; +import { describe, expect, it, vi } from 'vitest'; +import type { JsonRpcErrorResponse, JsonRpcSuccess } from '../jsonrpc.js'; +import { JsonRpcErrorCode } from '../jsonrpc.js'; +import { + handleMcpBody, + MCP_PROTOCOL_VERSION, + mcpHttpHandler, +} from '../handler.js'; +import { SMOKE_TOOL_NAME } from '../tools.js'; + +function rpc(method: string, params?: unknown, id: string | number | null = 1) { + return JSON.stringify({ jsonrpc: '2.0', id, method, ...(params !== undefined && { params }) }); +} + +describe('handleMcpBody — method dispatch', () => { + it('handles initialize', () => { + const res = handleMcpBody(rpc('initialize')) as JsonRpcSuccess; + const result = res.result as Record; + expect(result.protocolVersion).toBe(MCP_PROTOCOL_VERSION); + expect(result.capabilities).toEqual({ tools: { listChanged: false } }); + expect(result.serverInfo).toMatchObject({ name: '@accounter/mcp-server' }); + }); + + it('handles ping', () => { + const res = handleMcpBody(rpc('ping')) as JsonRpcSuccess; + expect(res.result).toEqual({}); + }); + + it('lists the smoke tool', () => { + const res = handleMcpBody(rpc('tools/list')) as JsonRpcSuccess; + const result = res.result as { tools: Array<{ name: string }> }; + expect(result.tools).toHaveLength(1); + expect(result.tools[0].name).toBe(SMOKE_TOOL_NAME); + }); + + it('calls the smoke tool and echoes the message', () => { + const res = handleMcpBody( + rpc('tools/call', { name: SMOKE_TOOL_NAME, arguments: { message: 'hi' } }), + ) as JsonRpcSuccess; + expect(res.result).toEqual({ content: [{ type: 'text', text: 'pong: hi' }], isError: false }); + }); + + it('returns InvalidParams for an unknown tool', () => { + const res = handleMcpBody(rpc('tools/call', { name: 'nope' })) as JsonRpcErrorResponse; + expect(res.error.code).toBe(JsonRpcErrorCode.InvalidParams); + }); + + it('returns MethodNotFound for an unsupported method', () => { + const res = handleMcpBody(rpc('resources/list')) as JsonRpcErrorResponse; + expect(res.error.code).toBe(JsonRpcErrorCode.MethodNotFound); + expect(res.error.message).toContain('resources/list'); + }); + + it('returns null for a notification (no id)', () => { + expect(handleMcpBody(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }))).toBeNull(); + }); +}); + +describe('handleMcpBody — malformed input', () => { + it('maps invalid JSON to ParseError with null id', () => { + const res = handleMcpBody('{ not json') as JsonRpcErrorResponse; + expect(res.id).toBeNull(); + expect(res.error.code).toBe(JsonRpcErrorCode.ParseError); + }); + + it('rejects JSON-RPC batches', () => { + const res = handleMcpBody('[{"jsonrpc":"2.0","id":1,"method":"ping"}]') as JsonRpcErrorResponse; + expect(res.error.code).toBe(JsonRpcErrorCode.InvalidRequest); + }); + + it('rejects a malformed request shape', () => { + const res = handleMcpBody('{"jsonrpc":"1.0","method":"ping"}') as JsonRpcErrorResponse; + expect(res.error.code).toBe(JsonRpcErrorCode.InvalidRequest); + }); +}); + +// --------------------------------------------------------------------------- +// HTTP adapter +// --------------------------------------------------------------------------- + +function mockReq(body: string): IncomingMessage { + return Readable.from([Buffer.from(body, 'utf8')]) as unknown as IncomingMessage; +} + +function mockRes() { + const res = { + writeHead: vi.fn(() => res), + end: vi.fn(() => res), + setHeader: vi.fn(() => res), + on: vi.fn(() => res), + }; + return res as unknown as ServerResponse & { + writeHead: ReturnType; + end: ReturnType; + setHeader: ReturnType; + on: ReturnType; + }; +} + +describe('mcpHttpHandler', () => { + it('responds 200 with the JSON-RPC result for a request', async () => { + const res = mockRes(); + await mcpHttpHandler(mockReq(rpc('tools/list')), res); + + expect(res.writeHead).toHaveBeenCalledWith(200, { 'Content-Type': 'application/json' }); + const body = JSON.parse(res.end.mock.calls[0][0] as string); + expect(body.result.tools[0].name).toBe(SMOKE_TOOL_NAME); + }); + + it('responds 202 with no body for a notification', async () => { + const res = mockRes(); + await mcpHttpHandler( + mockReq(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' })), + res, + ); + expect(res.writeHead).toHaveBeenCalledWith(202); + expect(res.end).toHaveBeenCalledWith(); + }); + + it('responds 413 when the body exceeds the size cap', async () => { + const res = mockRes(); + // Build a body larger than MAX_MCP_BODY_BYTES (1,000,000). + const huge = `{"jsonrpc":"2.0","id":1,"method":"ping","params":"${'x'.repeat(1_000_001)}"}`; + await mcpHttpHandler(mockReq(huge), res); + expect(res.writeHead).toHaveBeenCalledWith(413, { 'Content-Type': 'application/json' }); + }); +}); diff --git a/packages/mcp-server/src/mcp/__tests__/jsonrpc.test.ts b/packages/mcp-server/src/mcp/__tests__/jsonrpc.test.ts new file mode 100644 index 000000000..f2bf9feec --- /dev/null +++ b/packages/mcp-server/src/mcp/__tests__/jsonrpc.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { + asJsonRpcRequest, + failure, + isNotification, + JSON_RPC_VERSION, + JsonRpcErrorCode, + success, +} from '../jsonrpc.js'; + +describe('success / failure builders', () => { + it('builds a success envelope', () => { + expect(success(1, { ok: true })).toEqual({ + jsonrpc: JSON_RPC_VERSION, + id: 1, + result: { ok: true }, + }); + }); + + it('builds an error envelope and omits absent data', () => { + const err = failure('x', JsonRpcErrorCode.MethodNotFound, 'nope'); + expect(err).toEqual({ + jsonrpc: JSON_RPC_VERSION, + id: 'x', + error: { code: -32_601, message: 'nope' }, + }); + expect('data' in err.error).toBe(false); + }); + + it('includes data when provided', () => { + const err = failure(null, JsonRpcErrorCode.InvalidParams, 'bad', { field: 'name' }); + expect(err.error.data).toEqual({ field: 'name' }); + }); +}); + +describe('asJsonRpcRequest', () => { + it('accepts a valid request', () => { + expect(asJsonRpcRequest({ jsonrpc: '2.0', id: 1, method: 'ping' })).not.toBeNull(); + }); + + it('accepts a notification (no id)', () => { + const req = asJsonRpcRequest({ jsonrpc: '2.0', method: 'notifications/initialized' }); + expect(req).not.toBeNull(); + expect(isNotification(req!)).toBe(true); + }); + + it.each([ + ['null', null], + ['a string', 'hello'], + ['wrong version', { jsonrpc: '1.0', method: 'ping' }], + ['missing method', { jsonrpc: '2.0', id: 1 }], + ['non-string method', { jsonrpc: '2.0', method: 42 }], + ['object id', { jsonrpc: '2.0', id: {}, method: 'ping' }], + ['primitive params', { jsonrpc: '2.0', id: 1, method: 'ping', params: 'oops' }], + ])('rejects %s', (_label, value) => { + expect(asJsonRpcRequest(value)).toBeNull(); + }); + + it('accepts object and array params', () => { + expect(asJsonRpcRequest({ jsonrpc: '2.0', id: 1, method: 'x', params: { a: 1 } })).not.toBeNull(); + expect(asJsonRpcRequest({ jsonrpc: '2.0', id: 1, method: 'x', params: [1, 2] })).not.toBeNull(); + }); +}); + +describe('isNotification', () => { + it('is true only when id is absent', () => { + expect(isNotification({ jsonrpc: '2.0', method: 'x' })).toBe(true); + expect(isNotification({ jsonrpc: '2.0', id: null, method: 'x' })).toBe(false); + expect(isNotification({ jsonrpc: '2.0', id: 0, method: 'x' })).toBe(false); + }); +}); diff --git a/packages/mcp-server/src/mcp/handler.ts b/packages/mcp-server/src/mcp/handler.ts new file mode 100644 index 000000000..1cfd5fa72 --- /dev/null +++ b/packages/mcp-server/src/mcp/handler.ts @@ -0,0 +1,163 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { log } from '../logger.js'; +import { getServiceVersion, SERVICE_NAME } from '../version.js'; +import { + asJsonRpcRequest, + failure, + isNotification, + JsonRpcErrorCode, + success, + type JsonRpcRequest, + type JsonRpcResponse, +} from './jsonrpc.js'; +import { listedTools, runSmokeTool, SMOKE_TOOL_NAME } from './tools.js'; + +/** + * MCP transport route (Streamable HTTP) — request dispatch skeleton. + * + * Handles the JSON-RPC methods needed to establish a session and list tools. + * It is intentionally auth-agnostic for now (authentication and per-tool + * authorization are layered on in later steps) and performs NO upstream + * GraphQL calls. Unknown methods get a deterministic JSON-RPC "method not + * found" error. + */ + +/** MCP protocol revision this server implements. */ +export const MCP_PROTOCOL_VERSION = '2025-06-18'; + +export const MCP_SERVER_INFO = { + name: SERVICE_NAME, + version: getServiceVersion(), +}; + +/** Max accepted request body size (bounded input, per spec §9.1). */ +export const MAX_MCP_BODY_BYTES = 1_000_000; + +/** + * Dispatch a single JSON-RPC request to its MCP method handler. Returns the + * response, or `null` for notifications (which must not be answered). + */ +export function handleRpcRequest(request: JsonRpcRequest): JsonRpcResponse | null { + const { method } = request; + + // Notifications carry no id and expect no reply (e.g. notifications/initialized). + if (isNotification(request)) { + return null; + } + + const id = request.id ?? null; + + switch (method) { + case 'initialize': + return success(id, { + protocolVersion: MCP_PROTOCOL_VERSION, + capabilities: { tools: { listChanged: false } }, + serverInfo: MCP_SERVER_INFO, + }); + + case 'ping': + return success(id, {}); + + case 'tools/list': + return success(id, { tools: listedTools }); + + case 'tools/call': { + const params = (request.params ?? {}) as { name?: unknown; arguments?: unknown }; + if (params.name === SMOKE_TOOL_NAME) { + return success(id, runSmokeTool(params.arguments)); + } + // The `tools/call` method itself is supported; an unrecognized tool name + // is an invalid parameter, not an unsupported method. + return failure(id, JsonRpcErrorCode.InvalidParams, `Unknown tool: ${String(params.name)}`); + } + + default: + return failure(id, JsonRpcErrorCode.MethodNotFound, `Unsupported method: ${method}`); + } +} + +/** + * 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 { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return 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'); + } + + const request = asJsonRpcRequest(parsed); + if (!request) { + return failure(null, JsonRpcErrorCode.InvalidRequest, 'Invalid JSON-RPC 2.0 request'); + } + + return handleRpcRequest(request); +} + +function readBody(req: IncomingMessage, maxBytes: number): Promise { + return new Promise((resolve, reject) => { + let size = 0; + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => { + size += chunk.length; + if (size > maxBytes) { + // Pause (don't destroy) the stream so the caller can still write the + // 413 response before the socket is closed. + req.pause(); + reject(new Error('PAYLOAD_TOO_LARGE')); + return; + } + chunks.push(chunk); + }); + req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + req.on('error', reject); + }); +} + +function sendJson(res: ServerResponse, statusCode: number, body: unknown): void { + res.writeHead(statusCode, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); +} + +/** + * HTTP handler for `POST /mcp`. Reads the JSON-RPC body, dispatches it, and + * writes the response as `application/json`. Notifications get `202 Accepted` + * with no body. + */ +export async function mcpHttpHandler(req: IncomingMessage, res: ServerResponse): Promise { + let raw: string; + try { + raw = await readBody(req, MAX_MCP_BODY_BYTES); + } catch (error) { + if (error instanceof Error && error.message === 'PAYLOAD_TOO_LARGE') { + // Close the connection cleanly after the 413 is flushed — the request + // body was only partially consumed, so the socket cannot be reused. + res.setHeader('Connection', 'close'); + sendJson(res, 413, failure(null, JsonRpcErrorCode.InvalidRequest, 'Request body too large')); + res.on('finish', () => req.destroy()); + return; + } + log('error', 'failed to read MCP request body', { error: String(error) }); + sendJson(res, 400, failure(null, JsonRpcErrorCode.ParseError, 'Failed to read request body')); + return; + } + + const response = handleMcpBody(raw); + + if (response === null) { + // Notification: acknowledge without a JSON-RPC response body. + res.writeHead(202); + res.end(); + return; + } + + sendJson(res, 200, response); +} diff --git a/packages/mcp-server/src/mcp/jsonrpc.ts b/packages/mcp-server/src/mcp/jsonrpc.ts new file mode 100644 index 000000000..bf724aa66 --- /dev/null +++ b/packages/mcp-server/src/mcp/jsonrpc.ts @@ -0,0 +1,96 @@ +/** + * Minimal JSON-RPC 2.0 primitives used by the MCP transport. + * + * MCP speaks JSON-RPC 2.0. This module intentionally implements only the small + * subset the connector needs, avoiding a heavyweight framework dependency while + * the protocol surface is still small. It can be swapped for the official MCP + * SDK later without changing tool handlers. + */ + +export const JSON_RPC_VERSION = '2.0'; + +/** Standard JSON-RPC 2.0 error codes plus the range reserved for the server. */ +export const JsonRpcErrorCode = { + ParseError: -32_700, + InvalidRequest: -32_600, + MethodNotFound: -32_601, + InvalidParams: -32_602, + InternalError: -32_603, +} as const; + +export type JsonRpcId = string | number | null; + +export interface JsonRpcRequest { + jsonrpc: typeof JSON_RPC_VERSION; + id?: JsonRpcId; + method: string; + params?: unknown; +} + +export interface JsonRpcSuccess { + jsonrpc: typeof JSON_RPC_VERSION; + id: JsonRpcId; + result: unknown; +} + +export interface JsonRpcErrorResponse { + jsonrpc: typeof JSON_RPC_VERSION; + id: JsonRpcId; + error: { + code: number; + message: string; + data?: unknown; + }; +} + +export type JsonRpcResponse = JsonRpcSuccess | JsonRpcErrorResponse; + +export function success(id: JsonRpcId, result: unknown): JsonRpcSuccess { + return { jsonrpc: JSON_RPC_VERSION, id, result }; +} + +export function failure( + id: JsonRpcId, + code: number, + message: string, + data?: unknown, +): JsonRpcErrorResponse { + return { + jsonrpc: JSON_RPC_VERSION, + id, + error: { code, message, ...(data !== undefined && { data }) }, + }; +} + +/** + * Validate that an arbitrary parsed value is a well-formed JSON-RPC request. + * Returns the narrowed request or `null` when the shape is invalid. + */ +export function asJsonRpcRequest(value: unknown): JsonRpcRequest | null { + if (typeof value !== 'object' || value === null) { + return null; + } + const candidate = value as Record; + if (candidate.jsonrpc !== JSON_RPC_VERSION) { + return null; + } + if (typeof candidate.method !== 'string') { + return null; + } + const id = candidate.id; + if (id !== undefined && id !== null && typeof id !== 'string' && typeof id !== 'number') { + return null; + } + // Per JSON-RPC 2.0, `params` (when present) must be a structured value — + // an object or an array — never a primitive. + const params = candidate.params; + if (params !== undefined && (typeof params !== 'object' || params === null)) { + return null; + } + return candidate as unknown as JsonRpcRequest; +} + +/** A request with no `id` is a notification: the server must not reply. */ +export function isNotification(request: JsonRpcRequest): boolean { + return request.id === undefined; +} diff --git a/packages/mcp-server/src/mcp/tools.ts b/packages/mcp-server/src/mcp/tools.ts new file mode 100644 index 000000000..6861e28bb --- /dev/null +++ b/packages/mcp-server/src/mcp/tools.ts @@ -0,0 +1,70 @@ +/** + * Placeholder tool surface for the MCP transport skeleton. + * + * This file exists only to prove the list-tools / dispatch path end-to-end. It + * exposes a single, clearly-marked internal smoke tool and NO production + * capabilities. The curated, authorization-gated tool registry (with strict + * input/output schemas) replaces this in later steps; the smoke tool is removed + * or folded into that registry at wiring time. + */ + +/** JSON Schema (draft-07 subset) describing a tool's input. */ +export interface ToolInputSchema { + type: 'object'; + properties?: Record; + required?: string[]; + additionalProperties?: boolean; +} + +/** MCP tool descriptor as returned by `tools/list`. */ +export interface ToolDescriptor { + name: string; + description: string; + inputSchema: ToolInputSchema; +} + +/** MCP `tools/call` result content block. */ +export interface ToolTextContent { + type: 'text'; + text: string; +} + +export interface ToolCallResult { + content: ToolTextContent[]; + isError?: boolean; +} + +export const SMOKE_TOOL_NAME = 'accounter_smoke_ping'; + +export const smokeTool: ToolDescriptor = { + name: SMOKE_TOOL_NAME, + description: + 'Internal connectivity smoke test. Echoes the provided message back. Not a production capability — used only to verify the MCP transport is reachable.', + inputSchema: { + type: 'object', + properties: { + message: { + type: 'string', + description: 'Text to echo back.', + }, + }, + additionalProperties: false, + }, +}; + +/** Tools advertised by `tools/list` in the current (skeleton) phase. */ +export const listedTools: readonly ToolDescriptor[] = [smokeTool]; + +/** Execute the smoke tool. Pure and side-effect free — no upstream calls. */ +export function runSmokeTool(args: unknown): ToolCallResult { + const message = + typeof args === 'object' && + args !== null && + typeof (args as { message?: unknown }).message === 'string' + ? (args as { message: string }).message + : ''; + return { + content: [{ type: 'text', text: `pong: ${message}` }], + isError: false, + }; +} diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 27076267c..cc7f7f54a 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -1,41 +1,18 @@ -import { readFileSync } from 'node:fs'; import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; import { env } from './config/env.js'; import { log } from './logger.js'; +import { mcpHttpHandler } from './mcp/handler.js'; +import { getServiceVersion, SERVICE_NAME } from './version.js'; /** * HTTP server bootstrap for the MCP server. * - * Provides a plain `node:http` server with a `/health` endpoint and graceful - * shutdown. No MCP protocol logic lives here yet — the MCP transport route is - * added in a later step. Kept dependency-free (stdlib only) to avoid runtime - * framework lock-in. + * Provides a plain `node:http` server with a `/health` endpoint, the MCP + * transport route (`POST /mcp`), and graceful shutdown. Kept dependency-free + * (stdlib only) to avoid runtime framework lock-in. */ -export const SERVICE_NAME = '@accounter/mcp-server'; - -let cachedVersion: string | undefined; - -/** - * Resolve the package version at runtime without importing outside `rootDir`. - * The result is cached: the version cannot change while the process runs, so - * we avoid a synchronous disk read on every `/health` request. - */ -export function getServiceVersion(): string { - if (cachedVersion !== undefined) { - return cachedVersion; - } - try { - const packageJsonPath = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'); - const parsed = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { version?: string }; - cachedVersion = parsed.version ?? '0.0.0'; - } catch { - cachedVersion = '0.0.0'; - } - return cachedVersion; -} +export { getServiceVersion, SERVICE_NAME } from './version.js'; export interface HealthBody { status: 'ok'; @@ -51,6 +28,8 @@ export function sendJson(res: ServerResponse, statusCode: number, body: unknown) type RouteHandler = (req: IncomingMessage, res: ServerResponse) => void | Promise; +export const MCP_ROUTE_PATH = '/mcp'; + export const routes: Record> = { GET: { '/health': (_req, res) => { @@ -62,6 +41,15 @@ export const routes: Record> = { }; sendJson(res, 200, body); }, + // Streamable HTTP's optional GET (server-initiated SSE stream) is not + // supported in this phase — respond deterministically. + [MCP_ROUTE_PATH]: (_req, res) => { + res.writeHead(405, { 'Content-Type': 'application/json', Allow: 'POST' }); + res.end(JSON.stringify({ error: 'Method Not Allowed' })); + }, + }, + POST: { + [MCP_ROUTE_PATH]: mcpHttpHandler, }, }; diff --git a/packages/mcp-server/src/version.ts b/packages/mcp-server/src/version.ts new file mode 100644 index 000000000..f9969ff1f --- /dev/null +++ b/packages/mcp-server/src/version.ts @@ -0,0 +1,29 @@ +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** Canonical service name used in logs, health, and MCP server info. */ +export const SERVICE_NAME = '@accounter/mcp-server'; + +let cachedVersion: string | undefined; + +/** + * Resolve the package version at runtime without importing outside `rootDir`. + * Works from both `src/` (dev via tsx) and `dist/` (built), since each is one + * level below the package root. The result is cached — the version cannot + * change while the process runs, so we avoid a synchronous disk read on every + * `/health` request. + */ +export function getServiceVersion(): string { + if (cachedVersion !== undefined) { + return cachedVersion; + } + try { + const packageJsonPath = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'); + const parsed = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { version?: string }; + cachedVersion = parsed.version ?? '0.0.0'; + } catch { + cachedVersion = '0.0.0'; + } + return cachedVersion; +} From 1d3724c02b4a9da81bd7f5194143247f8a434ac9 Mon Sep 17 00:00:00 2001 From: Gil Gardosh Date: Tue, 21 Jul 2026 18:32:25 +0300 Subject: [PATCH 10/38] feat(mcp-server): request context & structured logging (MCP Prompt 05) (#3970) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mcp-server): request context and structured request logging (Prompt 05) Add observability groundwork per docs/mcp/spec.md §11.1. - src/context.ts: per-request context with request id + correlation id (inherited from inbound X-Correlation-Id when present), method/route, and a latency timer; per-request context store via WeakMap - src/logger.ts: context-bound request logger that stamps requestId, correlationId, method, and route on every entry; completion/latency helpers - server.ts: mint context per request, echo X-Correlation-Id, and log request start/completion (status + latencyMs) and failures - query strings and auth headers are never logged - unit tests for context, logger, and correlation-id propagation Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo * fix(mcp-server): robust route parsing and completion logging Address review comments: - guard new URL() in createRequestContext so a malformed req.url falls back to a path split instead of throwing out of context creation (which would hang the request) - log request completion on the response 'close' event (not 'finish') so an aborted/prematurely-closed connection still emits a completion log Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo --------- Co-authored-by: Claude --- packages/mcp-server/README.md | 15 +++- .../mcp-server/src/__tests__/context.test.ts | 58 ++++++++++++++ .../mcp-server/src/__tests__/logger.test.ts | 55 ++++++++++++- .../mcp-server/src/__tests__/server.test.ts | 21 +++++ packages/mcp-server/src/context.ts | 80 +++++++++++++++++++ packages/mcp-server/src/logger.ts | 46 ++++++++++- packages/mcp-server/src/server.ts | 27 +++++-- 7 files changed, 288 insertions(+), 14 deletions(-) create mode 100644 packages/mcp-server/src/__tests__/context.test.ts create mode 100644 packages/mcp-server/src/context.ts diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 2a36ebcfe..e25dea0fd 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -13,9 +13,18 @@ See the design docs: Early scaffolding. This package is being built incrementally following the prompt pack in the implementation blueprint. It currently contains the package skeleton, strict environment -configuration, a minimal HTTP server with a `/health` endpoint and graceful shutdown, and an MCP -transport route (`POST /mcp`) that speaks JSON-RPC 2.0 and lists an internal smoke tool. -Authentication, authorization, and production tools are not implemented yet. +configuration, a minimal HTTP server with a `/health` endpoint and graceful shutdown, an MCP +transport route (`POST /mcp`) that speaks JSON-RPC 2.0 and lists an internal smoke tool, and +per-request structured logging with request/correlation ids. Authentication, authorization, and +production tools are not implemented yet. + +## Observability + +Every request is assigned a `requestId` and a `correlationId` (the latter inherited from an inbound +`X-Correlation-Id` header when present, otherwise generated). The correlation id is echoed back on +the response. Structured JSON logs are emitted at request start and completion, carrying +`requestId`, `correlationId`, `method`, `route`, and — on completion — `status` and `latencyMs`. +Secrets and authorization headers are never logged. ## Running locally diff --git a/packages/mcp-server/src/__tests__/context.test.ts b/packages/mcp-server/src/__tests__/context.test.ts new file mode 100644 index 000000000..7ee12b715 --- /dev/null +++ b/packages/mcp-server/src/__tests__/context.test.ts @@ -0,0 +1,58 @@ +import type { IncomingMessage } from 'node:http'; +import { describe, expect, it } from 'vitest'; +import { + createRequestContext, + elapsedMs, + getRequestContext, + setRequestContext, +} from '../context.js'; + +function req(overrides: Partial = {}): IncomingMessage { + return { headers: {}, method: 'GET', url: '/health', ...overrides } as unknown as IncomingMessage; +} + +describe('createRequestContext', () => { + it('mints a request id and a correlation id', () => { + const ctx = createRequestContext(req()); + expect(ctx.requestId).toMatch(/[0-9a-f-]{36}/); + expect(ctx.correlationId).toMatch(/[0-9a-f-]{36}/); + expect(ctx.requestId).not.toBe(ctx.correlationId); + }); + + it('inherits the correlation id from the inbound header', () => { + const ctx = createRequestContext(req({ headers: { 'x-correlation-id': 'trace-abc' } })); + expect(ctx.correlationId).toBe('trace-abc'); + }); + + it('captures method and route without the query string', () => { + const ctx = createRequestContext(req({ method: 'POST', url: '/mcp?token=secret' })); + expect(ctx.method).toBe('POST'); + expect(ctx.route).toBe('/mcp'); + }); + + it('does not throw on a malformed URL, falling back to a path split', () => { + // `//` cannot be parsed relative to the placeholder host and throws in + // `new URL`; context creation must still succeed. + const ctx = createRequestContext(req({ url: '//?x=1' })); + expect(ctx.route).toBe('//'); + }); + + it('measures elapsed time as a non-negative integer', () => { + const ctx = createRequestContext(req()); + expect(elapsedMs(ctx)).toBeGreaterThanOrEqual(0); + expect(Number.isInteger(elapsedMs(ctx))).toBe(true); + }); +}); + +describe('request context store', () => { + it('associates and retrieves a context per request', () => { + const request = req(); + const ctx = createRequestContext(request); + setRequestContext(request, ctx); + expect(getRequestContext(request)).toBe(ctx); + }); + + it('returns undefined for a request with no stored context', () => { + expect(getRequestContext(req())).toBeUndefined(); + }); +}); diff --git a/packages/mcp-server/src/__tests__/logger.test.ts b/packages/mcp-server/src/__tests__/logger.test.ts index ddc6a0e1a..7dee3fc63 100644 --- a/packages/mcp-server/src/__tests__/logger.test.ts +++ b/packages/mcp-server/src/__tests__/logger.test.ts @@ -1,5 +1,14 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { log } from '../logger.js'; +import type { RequestContext } from '../context.js'; +import { completionFields, contextFields, createRequestLogger, log } from '../logger.js'; + +const ctx: RequestContext = { + requestId: 'req-1', + correlationId: 'corr-1', + method: 'POST', + route: '/mcp', + startTimeMs: performance.now(), +}; describe('log', () => { afterEach(() => { @@ -33,3 +42,47 @@ describe('log', () => { expect(fallback.originalMessage).toBe('with circular'); }); }); + +describe('createRequestLogger', () => { + afterEach(() => vi.restoreAllMocks()); + + it('merges context fields into every entry', () => { + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const logger = createRequestLogger(ctx); + logger.info('hello', { extra: true }); + + const entry = JSON.parse(spy.mock.calls[0][0] as string); + expect(entry).toMatchObject({ + requestId: 'req-1', + correlationId: 'corr-1', + method: 'POST', + route: '/mcp', + extra: true, + message: 'hello', + level: 'info', + }); + }); + + it('routes error entries to console.error', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + createRequestLogger(ctx).error('boom'); + expect(spy).toHaveBeenCalledTimes(1); + }); +}); + +describe('field helpers', () => { + it('contextFields excludes timing internals', () => { + expect(contextFields(ctx)).toEqual({ + requestId: 'req-1', + correlationId: 'corr-1', + method: 'POST', + route: '/mcp', + }); + }); + + it('completionFields includes status and latency', () => { + const fields = completionFields(ctx, 200); + expect(fields.status).toBe(200); + expect(fields.latencyMs).toBeGreaterThanOrEqual(0); + }); +}); diff --git a/packages/mcp-server/src/__tests__/server.test.ts b/packages/mcp-server/src/__tests__/server.test.ts index 829fb690e..ac54a1e77 100644 --- a/packages/mcp-server/src/__tests__/server.test.ts +++ b/packages/mcp-server/src/__tests__/server.test.ts @@ -22,10 +22,14 @@ function mockRes(): ServerResponse & { writeHead: vi.fn(), end: vi.fn(), setHeader: vi.fn(), + once: vi.fn(), + statusCode: 200, headersSent: false, } as unknown as ServerResponse & { writeHead: ReturnType; end: ReturnType; + setHeader: ReturnType; + once: ReturnType; }; } @@ -74,6 +78,23 @@ describe('requestHandler routing', () => { expect(res.end).toHaveBeenCalledWith('{"error":"Not found"}'); }); + it('sets an X-Correlation-Id response header', async () => { + const res = mockRes(); + await requestHandler(mockReq({ method: 'GET', url: '/health' }), res); + const call = res.setHeader.mock.calls.find(([name]) => name === 'X-Correlation-Id'); + expect(call).toBeDefined(); + expect(typeof call?.[1]).toBe('string'); + }); + + it('reuses an inbound correlation id', async () => { + const res = mockRes(); + await requestHandler( + mockReq({ method: 'GET', url: '/health', headers: { 'x-correlation-id': 'trace-123' } }), + res, + ); + expect(res.setHeader).toHaveBeenCalledWith('X-Correlation-Id', 'trace-123'); + }); + it('returns 404 for unsupported methods on a known path', async () => { const res = mockRes(); await requestHandler(mockReq({ method: 'POST', url: '/health' }), res); diff --git a/packages/mcp-server/src/context.ts b/packages/mcp-server/src/context.ts new file mode 100644 index 000000000..77898be92 --- /dev/null +++ b/packages/mcp-server/src/context.ts @@ -0,0 +1,80 @@ +import { randomUUID } from 'node:crypto'; +import type { IncomingMessage } from 'node:http'; + +/** + * Per-request context for observability. + * + * A `requestId` is minted fresh for every request. A `correlationId` is taken + * from the inbound `x-correlation-id` header when present (so a trace can span + * multiple hops) and otherwise generated. The correlation id is echoed back on + * the response and propagated to upstream calls in later steps. + */ +export interface RequestContext { + /** Unique id for this single request. */ + requestId: string; + /** Trace id spanning hops; inherited from the inbound header when present. */ + correlationId: string; + /** HTTP method (e.g. GET, POST). */ + method: string; + /** Request path (pathname only — never the query string). */ + route: string; + /** High-resolution start timestamp (ms) for latency measurement. */ + startTimeMs: number; +} + +/** Header used to inherit/propagate the correlation id. */ +export const CORRELATION_ID_HEADER = 'x-correlation-id'; + +export function generateId(): string { + return randomUUID(); +} + +function headerValue(req: IncomingMessage, name: string): string | undefined { + const value = req.headers[name]; + return Array.isArray(value) ? value[0] : value; +} + +/** + * Extract the pathname from a request URL, dropping the query string. Falls back + * to a manual split if the URL is malformed (so a bad `req.url` can never throw + * out of context creation and hang the request). + */ +function extractRoute(rawUrl: string | undefined): string { + const url = rawUrl ?? '/'; + try { + // Only the pathname is retained; the host is a placeholder and the query + // string is dropped so we never log sensitive query params. + return new URL(url, 'http://localhost').pathname; + } catch { + return url.split('?')[0]; + } +} + +/** Build a fresh {@link RequestContext} from an incoming request. */ +export function createRequestContext(req: IncomingMessage): RequestContext { + const inboundCorrelationId = headerValue(req, CORRELATION_ID_HEADER)?.trim(); + const route = extractRoute(req.url); + return { + requestId: generateId(), + correlationId: inboundCorrelationId || generateId(), + method: req.method ?? 'UNKNOWN', + route, + startTimeMs: performance.now(), + }; +} + +/** Elapsed time since the context was created, in whole milliseconds. */ +export function elapsedMs(context: RequestContext): number { + return Math.round(performance.now() - context.startTimeMs); +} + +// Associate a context with its request without mutating the request's type. +const contextByRequest = new WeakMap(); + +export function setRequestContext(req: IncomingMessage, context: RequestContext): void { + contextByRequest.set(req, context); +} + +export function getRequestContext(req: IncomingMessage): RequestContext | undefined { + return contextByRequest.get(req); +} diff --git a/packages/mcp-server/src/logger.ts b/packages/mcp-server/src/logger.ts index f7d5656a1..2bd21f85d 100644 --- a/packages/mcp-server/src/logger.ts +++ b/packages/mcp-server/src/logger.ts @@ -1,12 +1,12 @@ /* eslint-disable no-console */ +import type { RequestContext } from './context.js'; +import { elapsedMs } from './context.js'; /** * Minimal structured logger for the MCP server. * - * Emits single-line JSON so logs are machine-parseable in production. This is a - * deliberately small foundation; request-context propagation (correlation ids, - * per-request child loggers) is layered on in a later step. Secrets and - * authorization headers must never be passed as fields. + * Emits single-line JSON so logs are machine-parseable in production. Secrets + * and authorization headers must never be passed as fields. */ export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; @@ -46,3 +46,41 @@ export function log(level: LogLevel, message: string, fields?: Record { + return { + requestId: context.requestId, + correlationId: context.correlationId, + method: context.method, + route: context.route, + }; +} + +export interface RequestLogger { + debug(message: string, fields?: Record): void; + info(message: string, fields?: Record): void; + warn(message: string, fields?: Record): void; + error(message: string, fields?: Record): void; +} + +/** + * A logger bound to a request context. Every entry automatically carries the + * request id, correlation id, method, and route. + */ +export function createRequestLogger(context: RequestContext): RequestLogger { + const base = contextFields(context); + const emit = (level: LogLevel) => (message: string, fields?: Record) => + log(level, message, { ...base, ...fields }); + return { + debug: emit('debug'), + info: emit('info'), + warn: emit('warn'), + error: emit('error'), + }; +} + +/** Log fields describing request completion, including latency. */ +export function completionFields(context: RequestContext, status: number): Record { + return { status, latencyMs: elapsedMs(context) }; +} diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index cc7f7f54a..1871446f9 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -1,6 +1,7 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; import { env } from './config/env.js'; -import { log } from './logger.js'; +import { createRequestContext, elapsedMs, setRequestContext } from './context.js'; +import { completionFields, createRequestLogger, log } from './logger.js'; import { mcpHttpHandler } from './mcp/handler.js'; import { getServiceVersion, SERVICE_NAME } from './version.js'; @@ -54,18 +55,32 @@ export const routes: Record> = { }; export async function requestHandler(req: IncomingMessage, res: ServerResponse): Promise { - // Only the pathname matters for routing; the host is a placeholder so we never - // need to read config here. - const url = new URL(req.url ?? '/', 'http://localhost'); + const context = createRequestContext(req); + setRequestContext(req, context); + const logger = createRequestLogger(context); + + // Echo the correlation id so callers can tie their logs to ours. + res.setHeader('X-Correlation-Id', context.correlationId); + + logger.info('request started'); + // Log completion on `close` (not `finish`) so an aborted/prematurely-closed + // connection still emits a completion log instead of a silent blind spot. + res.once('close', () => { + logger.info('request completed', completionFields(context, res.statusCode)); + }); + try { - const handler = routes[req.method ?? '']?.[url.pathname]; + const handler = routes[context.method]?.[context.route]; if (handler) { await handler(req, res); } else { sendJson(res, 404, { error: 'Not found' }); } } catch (error) { - log('error', 'unhandled request error', { error: String(error), path: url.pathname }); + logger.error('request failed', { + error: String(error), + latencyMs: elapsedMs(context), + }); if (!res.headersSent) { sendJson(res, 500, { error: 'Internal server error' }); } From af5ae872c40b91479ad638db193837c9b61f76fd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 21 Jul 2026 15:34:41 +0000 Subject: [PATCH 11/38] chore(dependencies): updated changesets for modified dependencies --- .changeset/@accounter_client-3941-dependencies.md | 5 +++++ .changeset/@accounter_server-3941-dependencies.md | 7 +++++++ 2 files changed, 12 insertions(+) create mode 100644 .changeset/@accounter_client-3941-dependencies.md create mode 100644 .changeset/@accounter_server-3941-dependencies.md diff --git a/.changeset/@accounter_client-3941-dependencies.md b/.changeset/@accounter_client-3941-dependencies.md new file mode 100644 index 000000000..108db7c81 --- /dev/null +++ b/.changeset/@accounter_client-3941-dependencies.md @@ -0,0 +1,5 @@ +--- +"@accounter/client": patch +--- +dependencies updates: + - Updated dependency [`radix-ui@1.6.4` ↗︎](https://www.npmjs.com/package/radix-ui/v/1.6.4) (from `1.6.2`, in `dependencies`) diff --git a/.changeset/@accounter_server-3941-dependencies.md b/.changeset/@accounter_server-3941-dependencies.md new file mode 100644 index 000000000..3f5765dce --- /dev/null +++ b/.changeset/@accounter_server-3941-dependencies.md @@ -0,0 +1,7 @@ +--- +"@accounter/server": patch +--- +dependencies updates: + - Updated dependency [`@ai-sdk/anthropic@4.0.16` ↗︎](https://www.npmjs.com/package/@ai-sdk/anthropic/v/4.0.16) (from `4.0.15`, in `dependencies`) + - Updated dependency [`@graphql-tools/utils@11.2.2` ↗︎](https://www.npmjs.com/package/@graphql-tools/utils/v/11.2.2) (from `11.2.0`, in `dependencies`) + - Updated dependency [`ai@7.0.31` ↗︎](https://www.npmjs.com/package/ai/v/7.0.31) (from `7.0.29`, in `dependencies`) From 478fdd4e3a7f93fb6ac4b694f0f29e6bd5d2479a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 21 Jul 2026 15:35:26 +0000 Subject: [PATCH 12/38] chore(dependencies): updated changesets for modified dependencies --- .changeset/@accounter_client-3941-dependencies.md | 5 ----- .changeset/@accounter_server-3941-dependencies.md | 7 ------- 2 files changed, 12 deletions(-) delete mode 100644 .changeset/@accounter_client-3941-dependencies.md delete mode 100644 .changeset/@accounter_server-3941-dependencies.md diff --git a/.changeset/@accounter_client-3941-dependencies.md b/.changeset/@accounter_client-3941-dependencies.md deleted file mode 100644 index 108db7c81..000000000 --- a/.changeset/@accounter_client-3941-dependencies.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@accounter/client": patch ---- -dependencies updates: - - Updated dependency [`radix-ui@1.6.4` ↗︎](https://www.npmjs.com/package/radix-ui/v/1.6.4) (from `1.6.2`, in `dependencies`) diff --git a/.changeset/@accounter_server-3941-dependencies.md b/.changeset/@accounter_server-3941-dependencies.md deleted file mode 100644 index 3f5765dce..000000000 --- a/.changeset/@accounter_server-3941-dependencies.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@accounter/server": patch ---- -dependencies updates: - - Updated dependency [`@ai-sdk/anthropic@4.0.16` ↗︎](https://www.npmjs.com/package/@ai-sdk/anthropic/v/4.0.16) (from `4.0.15`, in `dependencies`) - - Updated dependency [`@graphql-tools/utils@11.2.2` ↗︎](https://www.npmjs.com/package/@graphql-tools/utils/v/11.2.2) (from `11.2.0`, in `dependencies`) - - Updated dependency [`ai@7.0.31` ↗︎](https://www.npmjs.com/package/ai/v/7.0.31) (from `7.0.29`, in `dependencies`) From 41a49881dae8b3cb2f092a8263f179ef69d30dc6 Mon Sep 17 00:00:00 2001 From: Gil Gardosh Date: Tue, 21 Jul 2026 18:41:13 +0300 Subject: [PATCH 13/38] feat(mcp-server): OAuth protected resource metadata route (MCP Prompt 06) (#3971) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mcp-server): OAuth protected resource metadata route (Prompt 06) Serve RFC 9728 protected-resource metadata so Claude clients can discover the Auth0 authorization server (docs/mcp/spec.md §6.2). - src/oauth/metadata.ts: config-driven buildProtectedResourceMetadata() and protectedResourceMetadataUrl() helpers; well-known path constant - GET /.well-known/oauth-protected-resource returns { resource, authorization_servers, bearer_methods_supported: ["header"] } as JSON, with resource matching MCP_PUBLIC_BASE_URL exactly - unit tests for the document shape + route-level test through requestHandler Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo * test(mcp-server): use vi.stubEnv for the metadata route test Address review: stub env vars via vi.stubEnv/vi.unstubAllEnvs instead of mutating process.env directly, to avoid cross-test pollution. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo --------- Co-authored-by: Claude --- packages/mcp-server/README.md | 23 ++++++- .../mcp-server/src/__tests__/server.test.ts | 24 +++++++ .../src/oauth/__tests__/metadata.test.ts | 64 +++++++++++++++++++ packages/mcp-server/src/oauth/metadata.ts | 46 +++++++++++++ packages/mcp-server/src/server.ts | 13 ++++ 5 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 packages/mcp-server/src/oauth/__tests__/metadata.test.ts create mode 100644 packages/mcp-server/src/oauth/metadata.ts diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index e25dea0fd..ef7bd0f34 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -14,9 +14,26 @@ See the design docs: Early scaffolding. This package is being built incrementally following the prompt pack in the implementation blueprint. It currently contains the package skeleton, strict environment configuration, a minimal HTTP server with a `/health` endpoint and graceful shutdown, an MCP -transport route (`POST /mcp`) that speaks JSON-RPC 2.0 and lists an internal smoke tool, and -per-request structured logging with request/correlation ids. Authentication, authorization, and -production tools are not implemented yet. +transport route (`POST /mcp`) that speaks JSON-RPC 2.0 and lists an internal smoke tool, per-request +structured logging with request/correlation ids, and the OAuth protected-resource metadata endpoint. +Token verification, authorization, and production tools are not implemented yet. + +## OAuth discovery + +`GET /.well-known/oauth-protected-resource` serves an +[RFC 9728](https://www.rfc-editor.org/rfc/rfc9728) protected-resource metadata document so Claude +clients can discover the authorization server: + +```json +{ + "resource": "", + "authorization_servers": [""], + "bearer_methods_supported": ["header"] +} +``` + +The document is fully config-driven (no hardcoded URLs). `bearer_methods_supported: ["header"]` +signals that tokens are accepted only via the `Authorization` header, never query params. ## Observability diff --git a/packages/mcp-server/src/__tests__/server.test.ts b/packages/mcp-server/src/__tests__/server.test.ts index ac54a1e77..af2233e77 100644 --- a/packages/mcp-server/src/__tests__/server.test.ts +++ b/packages/mcp-server/src/__tests__/server.test.ts @@ -1,5 +1,6 @@ import type { IncomingMessage, Server, ServerResponse } from 'node:http'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { PROTECTED_RESOURCE_METADATA_PATH } from '../oauth/metadata.js'; // Silence structured log output during server tests. vi.spyOn(console, 'log').mockImplementation(() => {}); @@ -70,6 +71,29 @@ describe('GET /health', () => { }); }); +describe('GET /.well-known/oauth-protected-resource', () => { + it('returns 200 with the config-driven metadata document', async () => { + vi.stubEnv('MCP_PUBLIC_BASE_URL', 'https://mcp.example.com'); + vi.stubEnv('AUTH0_ISSUER_URL', 'https://tenant.auth0.com/'); + vi.stubEnv('AUTH0_AUDIENCE', 'aud'); + vi.stubEnv('GRAPHQL_UPSTREAM_URL', 'http://localhost:4000/graphql'); + const { resetEnvCache } = await import('../config/env.js'); + resetEnvCache(); + + const res = mockRes(); + await requestHandler(mockReq({ method: 'GET', url: PROTECTED_RESOURCE_METADATA_PATH }), res); + + expect(res.writeHead).toHaveBeenCalledWith(200, { 'Content-Type': 'application/json' }); + const body = JSON.parse(res.end.mock.calls.at(-1)?.[0] as string); + expect(body.resource).toBe('https://mcp.example.com'); + expect(body.authorization_servers).toEqual(['https://tenant.auth0.com/']); + expect(body.bearer_methods_supported).toEqual(['header']); + + vi.unstubAllEnvs(); + resetEnvCache(); + }); +}); + describe('requestHandler routing', () => { it('returns 404 for unknown paths', async () => { const res = mockRes(); diff --git a/packages/mcp-server/src/oauth/__tests__/metadata.test.ts b/packages/mcp-server/src/oauth/__tests__/metadata.test.ts new file mode 100644 index 000000000..38bdf078f --- /dev/null +++ b/packages/mcp-server/src/oauth/__tests__/metadata.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import { + buildProtectedResourceMetadata, + PROTECTED_RESOURCE_METADATA_PATH, + protectedResourceMetadataUrl, +} from '../metadata.js'; + +describe('buildProtectedResourceMetadata', () => { + it('sets resource to the MCP public base URL exactly', () => { + const doc = buildProtectedResourceMetadata({ + resource: 'https://mcp.example.com', + authorizationServers: ['https://tenant.auth0.com/'], + }); + expect(doc.resource).toBe('https://mcp.example.com'); + }); + + it('lists the authorization servers', () => { + const doc = buildProtectedResourceMetadata({ + resource: 'https://mcp.example.com', + authorizationServers: ['https://tenant.auth0.com/'], + }); + expect(doc.authorization_servers).toEqual(['https://tenant.auth0.com/']); + }); + + it('declares header-only bearer methods (no query-param tokens)', () => { + const doc = buildProtectedResourceMetadata({ + resource: 'https://mcp.example.com', + authorizationServers: ['https://tenant.auth0.com/'], + }); + expect(doc.bearer_methods_supported).toEqual(['header']); + }); + + it('produces a stable document shape', () => { + const doc = buildProtectedResourceMetadata({ + resource: 'https://mcp.example.com', + authorizationServers: ['https://tenant.auth0.com/'], + }); + expect(Object.keys(doc).sort()).toEqual([ + 'authorization_servers', + 'bearer_methods_supported', + 'resource', + ]); + }); + + it('copies the authorization servers (no shared reference)', () => { + const servers = ['https://tenant.auth0.com/']; + const doc = buildProtectedResourceMetadata({ resource: 'https://x', authorizationServers: servers }); + expect(doc.authorization_servers).not.toBe(servers); + }); +}); + +describe('protectedResourceMetadataUrl', () => { + it('joins the public base URL with the well-known path', () => { + expect(protectedResourceMetadataUrl('https://mcp.example.com')).toBe( + `https://mcp.example.com${PROTECTED_RESOURCE_METADATA_PATH}`, + ); + }); + + it('normalizes a trailing slash on the base URL', () => { + expect(protectedResourceMetadataUrl('https://mcp.example.com/')).toBe( + `https://mcp.example.com${PROTECTED_RESOURCE_METADATA_PATH}`, + ); + }); +}); diff --git a/packages/mcp-server/src/oauth/metadata.ts b/packages/mcp-server/src/oauth/metadata.ts new file mode 100644 index 000000000..2cd568eaa --- /dev/null +++ b/packages/mcp-server/src/oauth/metadata.ts @@ -0,0 +1,46 @@ +/** + * OAuth 2.0 Protected Resource Metadata (RFC 9728). + * + * Claude clients discover how to authenticate to this MCP server by fetching + * the well-known document served here. The document points at the Auth0 + * authorization server(s) and declares that bearer tokens are accepted via the + * `Authorization` header only (never query params — see spec §6.5). + */ + +/** Well-known path for the protected resource metadata document. */ +export const PROTECTED_RESOURCE_METADATA_PATH = '/.well-known/oauth-protected-resource'; + +/** Inputs for building the metadata document. Kept explicit for testability. */ +export interface ProtectedResourceMetadataConfig { + /** Canonical resource identifier — the MCP server's public base URL. */ + resource: string; + /** Authorization server issuer URLs (Auth0). */ + authorizationServers: readonly string[]; +} + +/** Shape of the served metadata document (RFC 9728 subset). */ +export interface ProtectedResourceMetadata { + resource: string; + authorization_servers: string[]; + bearer_methods_supported: string[]; +} + +/** + * Build the protected resource metadata document. Pure and config-driven — no + * environment-specific values are hardcoded. + */ +export function buildProtectedResourceMetadata( + config: ProtectedResourceMetadataConfig, +): ProtectedResourceMetadata { + return { + resource: config.resource, + authorization_servers: [...config.authorizationServers], + // Tokens are accepted only in the Authorization header, never query params. + bearer_methods_supported: ['header'], + }; +} + +/** Absolute URL of the metadata document, given the MCP public base URL. */ +export function protectedResourceMetadataUrl(publicBaseUrl: string): string { + return `${publicBaseUrl.replace(/\/+$/, '')}${PROTECTED_RESOURCE_METADATA_PATH}`; +} diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 1871446f9..e86d30164 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -3,6 +3,10 @@ import { env } from './config/env.js'; import { createRequestContext, elapsedMs, setRequestContext } from './context.js'; import { completionFields, createRequestLogger, log } from './logger.js'; import { mcpHttpHandler } from './mcp/handler.js'; +import { + buildProtectedResourceMetadata, + PROTECTED_RESOURCE_METADATA_PATH, +} from './oauth/metadata.js'; import { getServiceVersion, SERVICE_NAME } from './version.js'; /** @@ -42,6 +46,15 @@ export const routes: Record> = { }; sendJson(res, 200, body); }, + // OAuth 2.0 Protected Resource Metadata (RFC 9728) — lets Claude clients + // discover the Auth0 authorization server for this resource. + [PROTECTED_RESOURCE_METADATA_PATH]: (_req, res) => { + const body = buildProtectedResourceMetadata({ + resource: env.server.publicBaseUrl, + authorizationServers: [env.auth0.issuerUrl], + }); + sendJson(res, 200, body); + }, // Streamable HTTP's optional GET (server-initiated SSE stream) is not // supported in this phase — respond deterministically. [MCP_ROUTE_PATH]: (_req, res) => { From 025af7391167c87160eb3fc985395064003bda0c Mon Sep 17 00:00:00 2001 From: Gil Gardosh Date: Tue, 21 Jul 2026 18:50:10 +0300 Subject: [PATCH 14/38] feat(mcp-server): 401 challenge helper for auth discovery (MCP Prompt 07) (#3972) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mcp-server): 401 challenge helper for auth discovery (Prompt 07) Add a standards-compliant unauthenticated response for MCP OAuth discovery (docs/mcp/spec.md §6.2, §10.1) and gate the MCP endpoint on token presence. - src/oauth/challenge.ts: buildWwwAuthenticateHeader() and sendUnauthorized() emitting 401 + WWW-Authenticate: Bearer with a resource_metadata pointer to the well-known document (transport-level, never a JSON-RPC/tool error) - handler.ts: hasBearerToken() presence check; POST /mcp without a bearer token now returns the 401 challenge (token validity verified in Prompt 08) - tests for the header builder, sendUnauthorized, the presence check, and the 401 path through mcpHttpHandler Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo * fix(mcp-server): RFC-compliant WWW-Authenticate header Address review: - only emit error_description alongside error (RFC 6750 §3) - escape " and \ in quoted-string parameter values (RFC 7235) - use vi.stubEnv in the 401 handler test instead of mutating process.env Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo --------- Co-authored-by: Claude --- packages/mcp-server/README.md | 11 ++- .../src/mcp/__tests__/handler.test.ts | 44 +++++++++- packages/mcp-server/src/mcp/handler.ts | 31 ++++++- .../src/oauth/__tests__/challenge.test.ts | 85 +++++++++++++++++++ packages/mcp-server/src/oauth/challenge.ts | 53 ++++++++++++ 5 files changed, 215 insertions(+), 9 deletions(-) create mode 100644 packages/mcp-server/src/oauth/__tests__/challenge.test.ts create mode 100644 packages/mcp-server/src/oauth/challenge.ts diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index ef7bd0f34..8014e686d 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -57,13 +57,16 @@ a grace period). ### MCP endpoint -The transport lives at `POST /mcp` and accepts JSON-RPC 2.0. It is currently **auth-agnostic** (the -OAuth challenge is added in a later step). Supported methods: `initialize`, `ping`, `tools/list`, -and `tools/call` (for the internal `accounter_smoke_ping` tool). Unknown methods return a -deterministic JSON-RPC `-32601` error; notifications receive `202 Accepted` with no body. +The transport lives at `POST /mcp` and accepts JSON-RPC 2.0. Requests **must** carry a bearer token +in the `Authorization` header — a request without one gets a `401` with a `WWW-Authenticate: Bearer` +header pointing at the protected-resource metadata document (token _validity_ is verified in a later +step). Supported methods: `initialize`, `ping`, `tools/list`, and `tools/call` (for the internal +`accounter_smoke_ping` tool). Unknown methods return a deterministic JSON-RPC `-32601` error; +notifications receive `202 Accepted` with no body. ```bash curl -sX POST http://localhost:3100/mcp -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer ' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' ``` diff --git a/packages/mcp-server/src/mcp/__tests__/handler.test.ts b/packages/mcp-server/src/mcp/__tests__/handler.test.ts index 11b326e57..13b7fed66 100644 --- a/packages/mcp-server/src/mcp/__tests__/handler.test.ts +++ b/packages/mcp-server/src/mcp/__tests__/handler.test.ts @@ -80,8 +80,13 @@ describe('handleMcpBody — malformed input', () => { // HTTP adapter // --------------------------------------------------------------------------- -function mockReq(body: string): IncomingMessage { - return Readable.from([Buffer.from(body, 'utf8')]) as unknown as IncomingMessage; +function mockReq( + body: string, + headers: Record = { authorization: 'Bearer test-token' }, +): IncomingMessage { + const stream = Readable.from([Buffer.from(body, 'utf8')]) as unknown as IncomingMessage; + stream.headers = headers as IncomingMessage['headers']; + return stream; } function mockRes() { @@ -126,4 +131,39 @@ describe('mcpHttpHandler', () => { await mcpHttpHandler(mockReq(huge), res); expect(res.writeHead).toHaveBeenCalledWith(413, { 'Content-Type': 'application/json' }); }); + + it('challenges with 401 + WWW-Authenticate when no bearer token is present', async () => { + vi.stubEnv('MCP_PUBLIC_BASE_URL', 'https://mcp.example.com'); + vi.stubEnv('AUTH0_ISSUER_URL', 'https://tenant.auth0.com/'); + vi.stubEnv('AUTH0_AUDIENCE', 'aud'); + vi.stubEnv('GRAPHQL_UPSTREAM_URL', 'http://localhost:4000/graphql'); + const { resetEnvCache } = await import('../../config/env.js'); + resetEnvCache(); + + const res = mockRes(); + await mcpHttpHandler(mockReq(rpc('tools/list'), {}), res); + + expect(res.writeHead).toHaveBeenCalledWith(401, { 'Content-Type': 'application/json' }); + const wwwAuth = res.setHeader.mock.calls.find(([name]) => name === 'WWW-Authenticate'); + expect(wwwAuth?.[1]).toContain( + 'resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"', + ); + vi.unstubAllEnvs(); + resetEnvCache(); + }); +}); + +describe('hasBearerToken', () => { + it('detects a bearer token (case-insensitive)', async () => { + const { hasBearerToken } = await import('../handler.js'); + expect(hasBearerToken(mockReq('', { authorization: 'Bearer abc' }))).toBe(true); + expect(hasBearerToken(mockReq('', { authorization: 'bearer abc' }))).toBe(true); + }); + + it('rejects a missing, empty, or non-bearer header', async () => { + const { hasBearerToken } = await import('../handler.js'); + expect(hasBearerToken(mockReq('', {}))).toBe(false); + expect(hasBearerToken(mockReq('', { authorization: 'Bearer ' }))).toBe(false); + expect(hasBearerToken(mockReq('', { authorization: 'Basic xyz' }))).toBe(false); + }); }); diff --git a/packages/mcp-server/src/mcp/handler.ts b/packages/mcp-server/src/mcp/handler.ts index 1cfd5fa72..0c1ab6ec5 100644 --- a/packages/mcp-server/src/mcp/handler.ts +++ b/packages/mcp-server/src/mcp/handler.ts @@ -1,5 +1,8 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; +import { env } from '../config/env.js'; import { log } from '../logger.js'; +import { sendUnauthorized } from '../oauth/challenge.js'; +import { protectedResourceMetadataUrl } from '../oauth/metadata.js'; import { getServiceVersion, SERVICE_NAME } from '../version.js'; import { asJsonRpcRequest, @@ -128,11 +131,33 @@ function sendJson(res: ServerResponse, statusCode: number, body: unknown): void } /** - * HTTP handler for `POST /mcp`. Reads the JSON-RPC body, dispatches it, and - * writes the response as `application/json`. Notifications get `202 Accepted` - * with no body. + * Whether the request carries a bearer token in the Authorization header. + * Token *validity* is verified in a later step; here we only detect presence + * so an unauthenticated call gets the standard 401 challenge (never a + * JSON-RPC/tool-level error). Query-param tokens are intentionally ignored. + */ +export function hasBearerToken(req: IncomingMessage): boolean { + const header = req.headers.authorization; + if (typeof header !== 'string') { + return false; + } + const match = /^Bearer[ ]+(.+)$/i.exec(header.trim()); + return match !== null && match[1].trim().length > 0; +} + +/** + * HTTP handler for `POST /mcp`. Enforces authentication (presence for now), + * reads the JSON-RPC body, dispatches it, and writes the response as + * `application/json`. Notifications get `202 Accepted` with no body. */ export async function mcpHttpHandler(req: IncomingMessage, res: ServerResponse): Promise { + if (!hasBearerToken(req)) { + sendUnauthorized(res, { + resourceMetadataUrl: protectedResourceMetadataUrl(env.server.publicBaseUrl), + }); + return; + } + let raw: string; try { raw = await readBody(req, MAX_MCP_BODY_BYTES); diff --git a/packages/mcp-server/src/oauth/__tests__/challenge.test.ts b/packages/mcp-server/src/oauth/__tests__/challenge.test.ts new file mode 100644 index 000000000..223679d35 --- /dev/null +++ b/packages/mcp-server/src/oauth/__tests__/challenge.test.ts @@ -0,0 +1,85 @@ +import type { ServerResponse } from 'node:http'; +import { describe, expect, it, vi } from 'vitest'; +import { buildWwwAuthenticateHeader, sendUnauthorized } from '../challenge.js'; + +const RESOURCE_METADATA_URL = + 'https://mcp.example.com/.well-known/oauth-protected-resource'; + +describe('buildWwwAuthenticateHeader', () => { + it('includes the resource_metadata pointer', () => { + expect(buildWwwAuthenticateHeader({ resourceMetadataUrl: RESOURCE_METADATA_URL })).toBe( + `Bearer resource_metadata="${RESOURCE_METADATA_URL}"`, + ); + }); + + it('appends error and error_description when provided', () => { + const header = buildWwwAuthenticateHeader({ + resourceMetadataUrl: RESOURCE_METADATA_URL, + error: 'invalid_token', + errorDescription: 'The access token expired', + }); + expect(header).toBe( + `Bearer resource_metadata="${RESOURCE_METADATA_URL}", error="invalid_token", error_description="The access token expired"`, + ); + }); + + it('omits error_description when error is absent (RFC 6750 §3)', () => { + const header = buildWwwAuthenticateHeader({ + resourceMetadataUrl: RESOURCE_METADATA_URL, + errorDescription: 'orphaned description', + }); + expect(header).toBe(`Bearer resource_metadata="${RESOURCE_METADATA_URL}"`); + expect(header).not.toContain('error_description'); + }); + + it('escapes quotes and backslashes in quoted-string values', () => { + const header = buildWwwAuthenticateHeader({ + resourceMetadataUrl: RESOURCE_METADATA_URL, + error: 'invalid_token', + errorDescription: 'has "quote" and \\ backslash', + }); + expect(header).toContain('error_description="has \\"quote\\" and \\\\ backslash"'); + }); +}); + +describe('sendUnauthorized', () => { + function mockRes() { + const res = { + setHeader: vi.fn(() => res), + writeHead: vi.fn(() => res), + end: vi.fn(() => res), + }; + return res as unknown as ServerResponse & { + setHeader: ReturnType; + writeHead: ReturnType; + end: ReturnType; + }; + } + + it('sets WWW-Authenticate and writes a 401 JSON body', () => { + const res = mockRes(); + sendUnauthorized(res, { resourceMetadataUrl: RESOURCE_METADATA_URL }); + + expect(res.setHeader).toHaveBeenCalledWith( + 'WWW-Authenticate', + `Bearer resource_metadata="${RESOURCE_METADATA_URL}"`, + ); + expect(res.writeHead).toHaveBeenCalledWith(401, { 'Content-Type': 'application/json' }); + const body = JSON.parse(res.end.mock.calls[0][0] as string); + expect(body.error).toBe('unauthorized'); + expect(typeof body.error_description).toBe('string'); + }); + + it('propagates a specific error code into the body and header', () => { + const res = mockRes(); + sendUnauthorized(res, { + resourceMetadataUrl: RESOURCE_METADATA_URL, + error: 'invalid_token', + errorDescription: 'expired', + }); + expect((res.setHeader.mock.calls[0][1] as string)).toContain('error="invalid_token"'); + const body = JSON.parse(res.end.mock.calls[0][0] as string); + expect(body.error).toBe('invalid_token'); + expect(body.error_description).toBe('expired'); + }); +}); diff --git a/packages/mcp-server/src/oauth/challenge.ts b/packages/mcp-server/src/oauth/challenge.ts new file mode 100644 index 000000000..63e0b5c50 --- /dev/null +++ b/packages/mcp-server/src/oauth/challenge.ts @@ -0,0 +1,53 @@ +import type { ServerResponse } from 'node:http'; + +/** + * Standardized 401 challenge for MCP auth discovery. + * + * When a request is unauthenticated, the server must respond with `401` and a + * `WWW-Authenticate: Bearer` header carrying a `resource_metadata` pointer to + * the protected-resource metadata document (RFC 9728 §5.1 / spec §6.2). Claude + * follows that pointer to begin the OAuth flow. + * + * This is a transport-level challenge — never a JSON-RPC/tool-level error. + */ +export interface UnauthorizedOptions { + /** Absolute URL of the protected-resource metadata document. */ + resourceMetadataUrl: string; + /** Optional RFC 6750 error code (e.g. `invalid_token`). */ + error?: string; + /** Optional human-readable description (must be safe for end users). */ + errorDescription?: string; +} + +/** Escape `"` and `\` so a value is a valid RFC 7235 quoted-string. */ +function quote(value: string): string { + return value.replace(/["\\]/g, '\\$&'); +} + +/** Build the `WWW-Authenticate` header value for a bearer challenge. */ +export function buildWwwAuthenticateHeader(options: UnauthorizedOptions): string { + const params = [`resource_metadata="${quote(options.resourceMetadataUrl)}"`]; + // Per RFC 6750 §3, error_description is only meaningful alongside error. + if (options.error) { + params.push(`error="${quote(options.error)}"`); + if (options.errorDescription) { + params.push(`error_description="${quote(options.errorDescription)}"`); + } + } + return `Bearer ${params.join(', ')}`; +} + +/** + * Write a standards-compliant 401 challenge to the response, including the + * `WWW-Authenticate` header with the `resource_metadata` pointer. + */ +export function sendUnauthorized(res: ServerResponse, options: UnauthorizedOptions): void { + res.setHeader('WWW-Authenticate', buildWwwAuthenticateHeader(options)); + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + error: options.error ?? 'unauthorized', + error_description: options.errorDescription ?? 'Authentication required', + }), + ); +} From 49f20c72edf3e2cc2d32e66b5082d1ebb82f69b4 Mon Sep 17 00:00:00 2001 From: Gil Gardosh Date: Tue, 21 Jul 2026 19:03:27 +0300 Subject: [PATCH 15/38] feat(mcp-server): bearer token extraction & Auth0 verification (MCP Prompt 08) (#3973) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mcp-server): bearer token extraction and Auth0 verification (Prompt 08) Verify Auth0 access tokens on the MCP endpoint (docs/mcp/spec.md §6.4, §8/§10.1), reusing the server package's jose-based JWKS approach. - src/auth/token.ts: extractBearerToken(); verifyAccessTokenWithKey() enforcing issuer/audience/signature/expiry and mapping claims to a typed AuthPrincipal (subject, scopes from scope+permissions, email); TokenVerificationError carries no token material; per-request principal store - src/auth/verifier.ts: env-backed verifyAccessToken() using a cached remote JWKS (createRemoteJWKSet) keyed by JWKS URL - handler.ts: POST /mcp now extracts + verifies the token; invalid/expired -> 401 with error="invalid_token"; missing -> 401 challenge; failures logged by reason only (never the token) - add jose dependency; unit tests for extraction, claim mapping, and valid/wrong-issuer/wrong-audience/expired/wrong-key verification Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo * fix(mcp-server): don't mask JWKS/infra failures as 401; tidy principal types Address review: - verifyAccessTokenWithKey only wraps genuine token-validation errors (by jose error code) into TokenVerificationError; infrastructure failures (e.g. a JWKS fetch timeout) propagate so the request surfaces as a 5xx instead of a misleading 401 - handler.authenticate rethrows non-TokenVerificationError so those become 500 - use the AuthPrincipal type directly in the import and authenticate() return type instead of Awaited> - tests: infra error propagation at both the verifier and handler layers Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo * prettier --------- Co-authored-by: Claude --- packages/mcp-server/README.md | 18 +- packages/mcp-server/package.json | 1 + .../src/auth/__tests__/token.test.ts | 135 ++++++++++++++ packages/mcp-server/src/auth/token.ts | 168 ++++++++++++++++++ packages/mcp-server/src/auth/verifier.ts | 38 ++++ .../src/mcp/__tests__/handler.test.ts | 63 ++++++- packages/mcp-server/src/mcp/handler.ts | 77 ++++++-- yarn.lock | 1 + 8 files changed, 472 insertions(+), 29 deletions(-) create mode 100644 packages/mcp-server/src/auth/__tests__/token.test.ts create mode 100644 packages/mcp-server/src/auth/token.ts create mode 100644 packages/mcp-server/src/auth/verifier.ts diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 8014e686d..979d64111 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -15,8 +15,9 @@ Early scaffolding. This package is being built incrementally following the promp implementation blueprint. It currently contains the package skeleton, strict environment configuration, a minimal HTTP server with a `/health` endpoint and graceful shutdown, an MCP transport route (`POST /mcp`) that speaks JSON-RPC 2.0 and lists an internal smoke tool, per-request -structured logging with request/correlation ids, and the OAuth protected-resource metadata endpoint. -Token verification, authorization, and production tools are not implemented yet. +structured logging with request/correlation ids, the OAuth protected-resource metadata endpoint, and +Auth0 bearer-token verification on the MCP endpoint. Fine-grained authorization and production tools +are not implemented yet. ## OAuth discovery @@ -57,12 +58,13 @@ a grace period). ### MCP endpoint -The transport lives at `POST /mcp` and accepts JSON-RPC 2.0. Requests **must** carry a bearer token -in the `Authorization` header — a request without one gets a `401` with a `WWW-Authenticate: Bearer` -header pointing at the protected-resource metadata document (token _validity_ is verified in a later -step). Supported methods: `initialize`, `ping`, `tools/list`, and `tools/call` (for the internal -`accounter_smoke_ping` tool). Unknown methods return a deterministic JSON-RPC `-32601` error; -notifications receive `202 Accepted` with no body. +The transport lives at `POST /mcp` and accepts JSON-RPC 2.0. Requests **must** carry a valid Auth0 +bearer token in the `Authorization` header. The token is verified (signature via the tenant JWKS, +plus `issuer`, `audience`, and expiry). A request with no token gets a `401` pointing at the +protected-resource metadata document; a request with an invalid/expired token gets a `401` with +`error="invalid_token"`. Supported methods: `initialize`, `ping`, `tools/list`, and `tools/call` +(for the internal `accounter_smoke_ping` tool). Unknown methods return a deterministic JSON-RPC +`-32601` error; notifications receive `202 Accepted` with no body. ```bash curl -sX POST http://localhost:3100/mcp -H 'Content-Type: application/json' \ diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index fe2d4d835..0b06ab47a 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -40,6 +40,7 @@ }, "dependencies": { "dotenv": "17.4.2", + "jose": "6.2.3", "zod": "4.4.3" }, "devDependencies": { diff --git a/packages/mcp-server/src/auth/__tests__/token.test.ts b/packages/mcp-server/src/auth/__tests__/token.test.ts new file mode 100644 index 000000000..69cf9b809 --- /dev/null +++ b/packages/mcp-server/src/auth/__tests__/token.test.ts @@ -0,0 +1,135 @@ +import type { IncomingMessage } from 'node:http'; +import { generateKeyPair, type JWTVerifyGetKey, type KeyObject, SignJWT } from 'jose'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { + extractBearerToken, + toPrincipal, + TokenVerificationError, + verifyAccessTokenWithKey, +} from '../token.js'; + +const ISSUER = 'https://tenant.auth0.com/'; +const AUDIENCE = 'https://api.accounter.example'; + +function req(headers: Record = {}): IncomingMessage { + return { headers } as unknown as IncomingMessage; +} + +describe('extractBearerToken', () => { + it('extracts the token from a well-formed header (case-insensitive)', () => { + expect(extractBearerToken(req({ authorization: 'Bearer abc.def.ghi' }))).toBe('abc.def.ghi'); + expect(extractBearerToken(req({ authorization: 'bearer abc' }))).toBe('abc'); + }); + + it('returns null when absent, empty, or non-bearer', () => { + expect(extractBearerToken(req())).toBeNull(); + expect(extractBearerToken(req({ authorization: 'Bearer ' }))).toBeNull(); + expect(extractBearerToken(req({ authorization: 'Basic xyz' }))).toBeNull(); + }); +}); + +describe('toPrincipal', () => { + it('parses subject, email, and scopes from scope + permissions', () => { + const principal = toPrincipal({ + sub: 'user-1', + iss: ISSUER, + aud: AUDIENCE, + email: 'a@b.com', + scope: 'read:charges read:tags', + permissions: ['read:reports', 'read:charges'], + exp: 123, + }); + expect(principal.subject).toBe('user-1'); + expect(principal.email).toBe('a@b.com'); + expect(principal.expiresAt).toBe(123); + expect([...principal.scopes].sort()).toEqual(['read:charges', 'read:reports', 'read:tags']); + }); + + it('throws when the subject claim is missing', () => { + expect(() => toPrincipal({ iss: ISSUER })).toThrow(TokenVerificationError); + }); +}); + +describe('verifyAccessTokenWithKey', () => { + let publicKey: KeyObject; + let privateKey: KeyObject; + + beforeAll(async () => { + ({ publicKey, privateKey } = (await generateKeyPair('RS256')) as { + publicKey: KeyObject; + privateKey: KeyObject; + }); + }); + + async function sign(overrides: { + issuer?: string; + audience?: string; + expiresIn?: string | number; + subject?: string; + }): Promise { + return new SignJWT({ scope: 'read:charges', email: 'a@b.com' }) + .setProtectedHeader({ alg: 'RS256' }) + .setIssuer(overrides.issuer ?? ISSUER) + .setAudience(overrides.audience ?? AUDIENCE) + .setSubject(overrides.subject ?? 'user-1') + .setIssuedAt() + .setExpirationTime(overrides.expiresIn ?? '2h') + .sign(privateKey); + } + + it('accepts a valid token and returns a principal', async () => { + const token = await sign({}); + const principal = await verifyAccessTokenWithKey(token, publicKey, { + issuer: ISSUER, + audience: AUDIENCE, + }); + expect(principal.subject).toBe('user-1'); + expect(principal.scopes).toContain('read:charges'); + }); + + it('rejects a token with the wrong issuer', async () => { + const token = await sign({ issuer: 'https://evil.example/' }); + await expect( + verifyAccessTokenWithKey(token, publicKey, { issuer: ISSUER, audience: AUDIENCE }), + ).rejects.toBeInstanceOf(TokenVerificationError); + }); + + it('rejects a token with the wrong audience', async () => { + const token = await sign({ audience: 'https://other.audience' }); + await expect( + verifyAccessTokenWithKey(token, publicKey, { issuer: ISSUER, audience: AUDIENCE }), + ).rejects.toBeInstanceOf(TokenVerificationError); + }); + + it('rejects an expired token', async () => { + const token = await sign({ expiresIn: Math.floor(Date.now() / 1000) - 60 }); + await expect( + verifyAccessTokenWithKey(token, publicKey, { issuer: ISSUER, audience: AUDIENCE }), + ).rejects.toBeInstanceOf(TokenVerificationError); + }); + + it('rejects a token signed by a different key', async () => { + const other = (await generateKeyPair('RS256')) as { privateKey: KeyObject }; + const token = await new SignJWT({}) + .setProtectedHeader({ alg: 'RS256' }) + .setIssuer(ISSUER) + .setAudience(AUDIENCE) + .setSubject('user-1') + .setExpirationTime('2h') + .sign(other.privateKey); + await expect( + verifyAccessTokenWithKey(token, publicKey, { issuer: ISSUER, audience: AUDIENCE }), + ).rejects.toBeInstanceOf(TokenVerificationError); + }); + + it('propagates infrastructure errors (e.g. JWKS timeout) instead of masking them as invalid', async () => { + const token = await sign({}); + const infra = Object.assign(new Error('jwks unreachable'), { code: 'ERR_JWKS_TIMEOUT' }); + const getKey: JWTVerifyGetKey = () => { + throw infra; + }; + await expect( + verifyAccessTokenWithKey(token, getKey, { issuer: ISSUER, audience: AUDIENCE }), + ).rejects.toBe(infra); + }); +}); diff --git a/packages/mcp-server/src/auth/token.ts b/packages/mcp-server/src/auth/token.ts new file mode 100644 index 000000000..b95e0dc26 --- /dev/null +++ b/packages/mcp-server/src/auth/token.ts @@ -0,0 +1,168 @@ +import type { IncomingMessage } from 'node:http'; +import { jwtVerify, type JWTPayload, type JWTVerifyGetKey, type KeyObject } from 'jose'; + +/** + * Bearer token extraction and Auth0 access-token verification. + * + * Tokens are only ever read from the `Authorization: Bearer ` header — + * never from query params (spec §6.5). Raw token values are never logged. + */ + +const BEARER_RE = /^Bearer[ ]+(.+)$/i; + +/** Extract the bearer token from the Authorization header, or `null`. */ +export function extractBearerToken(req: IncomingMessage): string | null { + const header = req.headers.authorization; + if (typeof header !== 'string') { + return null; + } + const match = BEARER_RE.exec(header.trim()); + if (!match) { + return null; + } + const token = match[1].trim(); + return token.length > 0 ? token : null; +} + +/** Authenticated caller derived from a verified access token. */ +export interface AuthPrincipal { + /** Subject claim (`sub`) — the stable user identifier. */ + subject: string; + /** Token issuer (`iss`). */ + issuer: string; + /** Audience claim (`aud`). */ + audience: string | string[] | undefined; + /** Granted scopes, parsed from the `scope` string and/or `permissions`. */ + scopes: readonly string[]; + /** Email claim, when present. */ + email: string | null; + /** Expiry as a UNIX timestamp (seconds), when present. */ + expiresAt: number | undefined; + /** Full verified claim set for downstream identity mapping. */ + claims: JWTPayload; +} + +/** Raised when an access token fails verification. Carries no token material. */ +export class TokenVerificationError extends Error { + /** RFC 6750 error code surfaced in the WWW-Authenticate challenge. */ + public readonly code = 'invalid_token'; + + constructor(message: string) { + super(message); + this.name = 'TokenVerificationError'; + } +} + +/** Parse space-delimited `scope` and array `permissions` into a scope list. */ +function parseScopes(payload: JWTPayload): string[] { + const scopes = new Set(); + if (typeof payload.scope === 'string') { + for (const scope of payload.scope.split(' ')) { + if (scope.trim()) { + scopes.add(scope.trim()); + } + } + } + const permissions = (payload as { permissions?: unknown }).permissions; + if (Array.isArray(permissions)) { + for (const permission of permissions) { + if (typeof permission === 'string' && permission.trim()) { + scopes.add(permission.trim()); + } + } + } + return [...scopes]; +} + +/** Map a verified JWT payload to an {@link AuthPrincipal}. */ +export function toPrincipal(payload: JWTPayload): AuthPrincipal { + if (!payload.sub) { + throw new TokenVerificationError('token is missing the subject (sub) claim'); + } + const email = payload['email']; + return { + subject: payload.sub, + issuer: payload.iss ?? '', + audience: payload.aud, + scopes: parseScopes(payload), + email: typeof email === 'string' ? email : null, + expiresAt: payload.exp, + claims: payload, + }; +} + +export interface VerifyOptions { + issuer: string; + audience: string; +} + +/** + * jose error codes that mean the *token itself* is invalid (client error → 401) + * as opposed to an infrastructure problem such as a JWKS fetch timeout, which + * should surface as a 5xx and must NOT be masked as an auth failure. + */ +const TOKEN_VALIDATION_CODES = new Set([ + 'ERR_JWT_EXPIRED', + 'ERR_JWT_NOT_ACTIVE', + 'ERR_JWT_CLAIM_VALIDATION_FAILED', + 'ERR_JWT_INVALID', + 'ERR_JWS_INVALID', + 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED', + 'ERR_JWKS_NO_MATCHING_KEY', + 'ERR_JWKS_MULTIPLE_MATCHING_KEYS', + 'ERR_JOSE_ALG_NOT_ALLOWED', + 'ERR_JOSE_NOT_SUPPORTED', +]); + +function isTokenValidationError(error: unknown): boolean { + const code = (error as { code?: unknown })?.code; + return typeof code === 'string' && TOKEN_VALIDATION_CODES.has(code); +} + +/** + * Verify an access token against the given key (a JWKS resolver or a public + * key), enforcing issuer, audience, signature, and expiry. Pure w.r.t. process + * env, so it is directly unit-testable with a local key. + * + * Throws {@link TokenVerificationError} only when the token itself is invalid + * (→ 401). Infrastructure failures (e.g. a JWKS fetch timeout) propagate + * unchanged so the caller can surface a 5xx instead of masking an outage as an + * authentication failure. + */ +export async function verifyAccessTokenWithKey( + token: string, + key: JWTVerifyGetKey | KeyObject | Uint8Array, + options: VerifyOptions, +): Promise { + try { + const verifyOptions = { issuer: options.issuer, audience: options.audience }; + // jose has separate overloads for a static key vs. a key-resolver function; + // branch so the correct one is selected. + const { payload } = + typeof key === 'function' + ? await jwtVerify(token, key, verifyOptions) + : await jwtVerify(token, key, verifyOptions); + return toPrincipal(payload); + } catch (error) { + if (error instanceof TokenVerificationError) { + throw error; + } + if (isTokenValidationError(error)) { + // Normalize to a safe, token-free message. + throw new TokenVerificationError(error instanceof Error ? error.message : 'invalid token'); + } + // Infrastructure/unexpected error — let it propagate (becomes a 5xx). + throw error; + } +} + +// Associate a verified principal with its request for downstream steps. +const principalByRequest = new WeakMap(); + +export function setAuthPrincipal(req: IncomingMessage, principal: AuthPrincipal): void { + principalByRequest.set(req, principal); +} + +export function getAuthPrincipal(req: IncomingMessage): AuthPrincipal | undefined { + return principalByRequest.get(req); +} diff --git a/packages/mcp-server/src/auth/verifier.ts b/packages/mcp-server/src/auth/verifier.ts new file mode 100644 index 000000000..036d4e9ad --- /dev/null +++ b/packages/mcp-server/src/auth/verifier.ts @@ -0,0 +1,38 @@ +import { createRemoteJWKSet, type JWTVerifyGetKey } from 'jose'; +import { env } from '../config/env.js'; +import { verifyAccessTokenWithKey, type AuthPrincipal } from './token.js'; + +/** + * Default access-token verifier wired to the configured Auth0 tenant. + * + * The JWKS is fetched lazily and cached per JWKS URL (jose's + * `createRemoteJWKSet` also caches keys and handles rotation), mirroring the + * server package's auth setup. + */ + +const jwksCache = new Map(); + +function getRemoteJwks(jwksUrl: string): JWTVerifyGetKey { + let jwks = jwksCache.get(jwksUrl); + if (!jwks) { + jwks = createRemoteJWKSet(new URL(jwksUrl)); + jwksCache.set(jwksUrl, jwks); + } + return jwks; +} + +/** + * Verify an Auth0 access token using the configured issuer, audience, and + * JWKS. Throws `TokenVerificationError` on any failure. + */ +export function verifyAccessToken(token: string): Promise { + return verifyAccessTokenWithKey(token, getRemoteJwks(env.auth0.jwksUrl), { + issuer: env.auth0.issuerUrl, + audience: env.auth0.audience, + }); +} + +/** Test-only: clear the memoized JWKS resolvers. */ +export function resetJwksCache(): void { + jwksCache.clear(); +} diff --git a/packages/mcp-server/src/mcp/__tests__/handler.test.ts b/packages/mcp-server/src/mcp/__tests__/handler.test.ts index 13b7fed66..aea4b29e6 100644 --- a/packages/mcp-server/src/mcp/__tests__/handler.test.ts +++ b/packages/mcp-server/src/mcp/__tests__/handler.test.ts @@ -1,15 +1,28 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; import { Readable } from 'node:stream'; -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { TokenVerificationError } from '../../auth/token.js'; +import { verifyAccessToken } from '../../auth/verifier.js'; +import { handleMcpBody, MCP_PROTOCOL_VERSION, mcpHttpHandler } from '../handler.js'; import type { JsonRpcErrorResponse, JsonRpcSuccess } from '../jsonrpc.js'; import { JsonRpcErrorCode } from '../jsonrpc.js'; -import { - handleMcpBody, - MCP_PROTOCOL_VERSION, - mcpHttpHandler, -} from '../handler.js'; import { SMOKE_TOOL_NAME } from '../tools.js'; +// The MCP handler verifies bearer tokens via the env-backed verifier, which +// would otherwise fetch a remote JWKS. Mock it so tests stay hermetic. +vi.mock('../../auth/verifier.js', () => ({ verifyAccessToken: vi.fn() })); +const mockVerify = vi.mocked(verifyAccessToken); + +const PRINCIPAL = { + subject: 'user-1', + issuer: 'https://tenant.auth0.com/', + audience: 'aud', + scopes: [], + email: null, + expiresAt: undefined, + claims: { sub: 'user-1' }, +}; + function rpc(method: string, params?: unknown, id: string | number | null = 1) { return JSON.stringify({ jsonrpc: '2.0', id, method, ...(params !== undefined && { params }) }); } @@ -105,6 +118,12 @@ function mockRes() { } describe('mcpHttpHandler', () => { + beforeEach(() => { + // A valid bearer token resolves to a principal by default. + mockVerify.mockReset(); + mockVerify.mockResolvedValue(PRINCIPAL); + }); + it('responds 200 with the JSON-RPC result for a request', async () => { const res = mockRes(); await mcpHttpHandler(mockReq(rpc('tools/list')), res); @@ -148,9 +167,41 @@ describe('mcpHttpHandler', () => { expect(wwwAuth?.[1]).toContain( 'resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"', ); + expect(mockVerify).not.toHaveBeenCalled(); vi.unstubAllEnvs(); resetEnvCache(); }); + + it('challenges with 401 + error="invalid_token" when the token fails verification', async () => { + vi.stubEnv('MCP_PUBLIC_BASE_URL', 'https://mcp.example.com'); + vi.stubEnv('AUTH0_ISSUER_URL', 'https://tenant.auth0.com/'); + vi.stubEnv('AUTH0_AUDIENCE', 'aud'); + vi.stubEnv('GRAPHQL_UPSTREAM_URL', 'http://localhost:4000/graphql'); + const { resetEnvCache } = await import('../../config/env.js'); + resetEnvCache(); + mockVerify.mockRejectedValue(new TokenVerificationError('expired')); + + const res = mockRes(); + await mcpHttpHandler(mockReq(rpc('tools/list'), { authorization: 'Bearer bad' }), res); + + expect(res.writeHead).toHaveBeenCalledWith(401, { 'Content-Type': 'application/json' }); + const wwwAuth = res.setHeader.mock.calls.find(([name]) => name === 'WWW-Authenticate'); + expect(wwwAuth?.[1]).toContain('error="invalid_token"'); + vi.unstubAllEnvs(); + resetEnvCache(); + }); + + it('propagates infrastructure errors instead of returning a misleading 401', async () => { + // An error without a token-validation code (e.g. a JWKS outage) must bubble + // up so the request becomes a 5xx, not a 401. + mockVerify.mockRejectedValue(new Error('jwks endpoint unreachable')); + + const res = mockRes(); + await expect(mcpHttpHandler(mockReq(rpc('tools/list')), res)).rejects.toThrow( + 'jwks endpoint unreachable', + ); + expect(res.writeHead).not.toHaveBeenCalledWith(401, expect.anything()); + }); }); describe('hasBearerToken', () => { diff --git a/packages/mcp-server/src/mcp/handler.ts b/packages/mcp-server/src/mcp/handler.ts index 0c1ab6ec5..7d900fc45 100644 --- a/packages/mcp-server/src/mcp/handler.ts +++ b/packages/mcp-server/src/mcp/handler.ts @@ -1,6 +1,14 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; +import { + extractBearerToken, + setAuthPrincipal, + TokenVerificationError, + type AuthPrincipal, +} from '../auth/token.js'; +import { verifyAccessToken } from '../auth/verifier.js'; import { env } from '../config/env.js'; -import { log } from '../logger.js'; +import { getRequestContext } from '../context.js'; +import { createRequestLogger, log } from '../logger.js'; import { sendUnauthorized } from '../oauth/challenge.js'; import { protectedResourceMetadataUrl } from '../oauth/metadata.js'; import { getServiceVersion, SERVICE_NAME } from '../version.js'; @@ -132,29 +140,68 @@ function sendJson(res: ServerResponse, statusCode: number, body: unknown): void /** * Whether the request carries a bearer token in the Authorization header. - * Token *validity* is verified in a later step; here we only detect presence - * so an unauthenticated call gets the standard 401 challenge (never a - * JSON-RPC/tool-level error). Query-param tokens are intentionally ignored. + * Query-param tokens are intentionally ignored. Kept for callers that only + * need presence; the handler itself verifies the token. */ export function hasBearerToken(req: IncomingMessage): boolean { - const header = req.headers.authorization; - if (typeof header !== 'string') { - return false; - } - const match = /^Bearer[ ]+(.+)$/i.exec(header.trim()); - return match !== null && match[1].trim().length > 0; + return extractBearerToken(req) !== null; } /** - * HTTP handler for `POST /mcp`. Enforces authentication (presence for now), - * reads the JSON-RPC body, dispatches it, and writes the response as - * `application/json`. Notifications get `202 Accepted` with no body. + * Authenticate the request: extract and verify the bearer token. On success + * returns the principal (and stores it on the request); on failure writes the + * appropriate 401 challenge and returns `null`. Never returns a JSON-RPC/tool + * error for auth problems, and never logs the token. */ -export async function mcpHttpHandler(req: IncomingMessage, res: ServerResponse): Promise { - if (!hasBearerToken(req)) { +async function authenticate( + req: IncomingMessage, + res: ServerResponse, +): Promise { + const token = extractBearerToken(req); + if (!token) { sendUnauthorized(res, { resourceMetadataUrl: protectedResourceMetadataUrl(env.server.publicBaseUrl), }); + return null; + } + + try { + const principal = await verifyAccessToken(token); + setAuthPrincipal(req, principal); + return principal; + } catch (error) { + // Only an invalid token is a 401; infrastructure failures (e.g. a JWKS + // outage) propagate so the request surfaces as a 5xx rather than a + // misleading auth error. + if (!(error instanceof TokenVerificationError)) { + throw error; + } + // Log the reason only — never the token. + const context = getRequestContext(req); + if (context) { + createRequestLogger(context).warn('access token verification failed', { + reason: error.message, + }); + } else { + log('warn', 'access token verification failed', { reason: error.message }); + } + sendUnauthorized(res, { + resourceMetadataUrl: protectedResourceMetadataUrl(env.server.publicBaseUrl), + error: 'invalid_token', + errorDescription: 'The access token is invalid or expired', + }); + return null; + } +} + +/** + * HTTP handler for `POST /mcp`. Authenticates the caller (extract + verify the + * bearer token), reads the JSON-RPC body, dispatches it, and writes the + * response as `application/json`. Notifications get `202 Accepted` with no body. + */ +export async function mcpHttpHandler(req: IncomingMessage, res: ServerResponse): Promise { + const principal = await authenticate(req, res); + if (!principal) { return; } diff --git a/yarn.lock b/yarn.lock index 123cc4ee4..3e4f8d14e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -270,6 +270,7 @@ __metadata: dependencies: "@types/node": "npm:25.9.5" dotenv: "npm:17.4.2" + jose: "npm:6.2.3" tsx: "npm:4.23.1" typescript: "npm:6.0.3" vitest: "npm:4.1.10" From d9b688d86bd863a26ea608c70949dcdb2d88cbd8 Mon Sep 17 00:00:00 2001 From: Gil Gardosh Date: Sun, 26 Jul 2026 18:20:00 +0300 Subject: [PATCH 16/38] feat(server): expose caller's own business memberships (MCP Prompt 08a) (#4050) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(server): expose caller's own business memberships (MCP Prompt 08a) Add a `myMemberships` GraphQL query to the auth module that returns the authenticated caller's own business memberships. Gated with `@requiresAuth` only (never `@requiresRole`, never dependent on a selected business scope) so the MCP connector can read a user's businesses before any scope is chosen. The resolver reuses the memberships already resolved on the request auth context (`AuthContextProvider.getAuthContext()`) — no new DB query. An authenticated user with no memberships returns an empty list, not an error. The `MyBusinessMembership` type is prefixed to avoid colliding with the existing `common` module `BusinessMembership` type, and uses `roleId` to map cleanly onto the MCP connector's internal membership shape (Prompt 08b). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo * fix(server): role-gate myMemberships and give MyBusinessMembership an id - Gate `myMemberships` with `@requiresAnyRole(["business_owner", "accountant"])` instead of leaving it authenticated-only. - Add a required `id: ID!` to `MyBusinessMembership` (composite of the caller's user id and the business id) and drop the strict-id-in-types eslint workaround. - Populate the composite `id` in the resolver and update the doc comment. - Strengthen the error-wrapping test to assert the wrapper's message, `extensions.code`, and original error, and add a test that GraphQLErrors from the auth context are rethrown unchanged (addresses PR review comment). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Dkv1EjQ1ygKUFrmEWUhS5p --------- Co-authored-by: Claude --- .../auth/__tests__/memberships.test.ts | 118 ++++++++++++++++++ packages/server/src/modules/auth/index.ts | 3 +- .../auth/resolvers/memberships.resolver.ts | 42 +++++++ .../src/modules/auth/typeDefs/auth.graphql.ts | 12 ++ 4 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 packages/server/src/modules/auth/__tests__/memberships.test.ts create mode 100644 packages/server/src/modules/auth/resolvers/memberships.resolver.ts diff --git a/packages/server/src/modules/auth/__tests__/memberships.test.ts b/packages/server/src/modules/auth/__tests__/memberships.test.ts new file mode 100644 index 000000000..d47f27451 --- /dev/null +++ b/packages/server/src/modules/auth/__tests__/memberships.test.ts @@ -0,0 +1,118 @@ +import { GraphQLError, GraphQLResolveInfo } from 'graphql'; +import { describe, expect, it, vi } from 'vitest'; +import { AuthContextProvider } from '../providers/auth-context.provider.js'; +import { membershipsResolvers } from '../resolvers/memberships.resolver.js'; + +const myMembershipsResolver = membershipsResolvers.Query?.myMemberships as ( + source: unknown, + args: Record, + context: { injector: { get(token: unknown): T } }, + info: GraphQLResolveInfo, +) => Promise< + Array<{ id: string; businessId: string; roleId: string; businessName: string | null }> +>; + +const mockInfo = {} as GraphQLResolveInfo; + +function contextWith(getAuthContext: ReturnType) { + const mockAuthProvider = { getAuthContext }; + return { + injector: { + get(token: unknown): T { + if (token === AuthContextProvider) { + return mockAuthProvider as T; + } + throw new Error('Unexpected provider requested'); + }, + }, + }; +} + +describe('myMemberships resolver', () => { + it('returns all memberships for a multi-business user', async () => { + const getAuthContext = vi.fn().mockResolvedValue({ + authType: 'jwt', + user: { userId: 'user-1' }, + tenant: { businessId: 'business-1', roleId: 'business_owner' }, + memberships: [ + { businessId: 'business-1', roleId: 'business_owner', businessName: 'Acme' }, + { businessId: 'business-2', roleId: 'accountant' }, + ], + }); + + const result = await myMembershipsResolver({}, {}, contextWith(getAuthContext), mockInfo); + + expect(result).toEqual([ + { + id: 'user-1:business-1', + businessId: 'business-1', + roleId: 'business_owner', + businessName: 'Acme', + }, + { + id: 'user-1:business-2', + businessId: 'business-2', + roleId: 'accountant', + businessName: null, + }, + ]); + }); + + it('returns an empty list for an authenticated user with no memberships', async () => { + const getAuthContext = vi.fn().mockResolvedValue({ + authType: 'jwt', + tenant: { businessId: '', roleId: undefined }, + memberships: [], + }); + + const result = await myMembershipsResolver({}, {}, contextWith(getAuthContext), mockInfo); + + expect(result).toEqual([]); + }); + + it('returns an empty list when the auth context has no memberships field', async () => { + const getAuthContext = vi.fn().mockResolvedValue({ + authType: 'jwt', + tenant: { businessId: 'business-1' }, + }); + + const result = await myMembershipsResolver({}, {}, contextWith(getAuthContext), mockInfo); + + expect(result).toEqual([]); + }); + + it('wraps unexpected failures in a GraphQLError with the resolver code and original error', async () => { + const originalError = new Error('db down'); + const getAuthContext = vi.fn().mockRejectedValue(originalError); + + const error = await myMembershipsResolver( + {}, + {}, + contextWith(getAuthContext), + mockInfo, + ).then( + () => { + throw new Error('expected myMemberships to reject'); + }, + (thrown: unknown) => thrown, + ); + + expect(error).toBeInstanceOf(GraphQLError); + expect(error).toMatchObject({ + message: 'Failed to resolve memberships', + extensions: { code: 'MEMBERSHIPS_RESOLUTION_FAILED' }, + originalError, + }); + }); + + it('rethrows GraphQLErrors from the auth context unchanged', async () => { + const authError = new GraphQLError('Unauthenticated', { + extensions: { code: 'UNAUTHENTICATED' }, + }); + const getAuthContext = vi.fn().mockRejectedValue(authError); + + await expect( + myMembershipsResolver({}, {}, contextWith(getAuthContext), mockInfo), + ).rejects.toBe(authError); + }); +}); diff --git a/packages/server/src/modules/auth/index.ts b/packages/server/src/modules/auth/index.ts index 439b3092e..3acfe568d 100644 --- a/packages/server/src/modules/auth/index.ts +++ b/packages/server/src/modules/auth/index.ts @@ -10,6 +10,7 @@ import { SuperAdminProvider } from './providers/super-admin.provider.js'; import { apiKeysResolvers } from './resolvers/api-keys.resolver.js'; import { businessUsersResolvers } from './resolvers/business-users.resolver.js'; import { invitationsResolvers } from './resolvers/invitations.resolver.js'; +import { membershipsResolvers } from './resolvers/memberships.resolver.js'; import auth from './typeDefs/auth.graphql.js'; const __dirname = import.meta.dirname; @@ -18,7 +19,7 @@ export const authModule = createModule({ id: 'auth', dirname: __dirname, typeDefs: [auth], - resolvers: [invitationsResolvers, apiKeysResolvers, businessUsersResolvers], + resolvers: [invitationsResolvers, apiKeysResolvers, businessUsersResolvers, membershipsResolvers], providers: () => [ AcceptInvitationsProvider, Auth0ManagementProvider, diff --git a/packages/server/src/modules/auth/resolvers/memberships.resolver.ts b/packages/server/src/modules/auth/resolvers/memberships.resolver.ts new file mode 100644 index 000000000..0af938e73 --- /dev/null +++ b/packages/server/src/modules/auth/resolvers/memberships.resolver.ts @@ -0,0 +1,42 @@ +import { GraphQLError } from 'graphql'; +import { AuthContextProvider } from '../providers/auth-context.provider.js'; +import type { AuthModule } from '../types.js'; + +/** + * Expose the authenticated caller's own business memberships. + * + * Gated with `@requiresAnyRole(["business_owner", "accountant"])`: only callers + * holding one of these roles may enumerate their memberships. + * + * The memberships are already resolved on the request auth context + * (`AuthContextProvider.getAuthContext()`), so no additional DB query is needed. + * An authenticated user with no memberships returns an empty list, not an error. + * + * Each membership's `id` is a composite of the caller's user id and the business + * id, since a membership has no single-column identity of its own. + */ +export const membershipsResolvers: AuthModule.Resolvers = { + Query: { + myMemberships: async (_, __, { injector }) => { + try { + const authContext = await injector.get(AuthContextProvider).getAuthContext(); + const userId = authContext?.user?.userId ?? ''; + return (authContext?.memberships ?? []).map(membership => ({ + id: `${userId}:${membership.businessId}`, + businessId: membership.businessId, + roleId: membership.roleId, + businessName: membership.businessName ?? null, + })); + } catch (error) { + if (error instanceof GraphQLError) { + throw error; + } + + throw new GraphQLError('Failed to resolve memberships', { + originalError: error instanceof Error ? error : undefined, + extensions: { code: 'MEMBERSHIPS_RESOLUTION_FAILED' }, + }); + } + }, + }, +}; diff --git a/packages/server/src/modules/auth/typeDefs/auth.graphql.ts b/packages/server/src/modules/auth/typeDefs/auth.graphql.ts index 5abe5057c..45f33d86c 100644 --- a/packages/server/src/modules/auth/typeDefs/auth.graphql.ts +++ b/packages/server/src/modules/auth/typeDefs/auth.graphql.ts @@ -12,6 +12,18 @@ export default gql` listApiKeys: [ApiKey!]! @requiresRole(role: "business_owner") listBusinessUsers: [BusinessUser!]! @requiresRole(role: "business_owner") listInvitations: [Invitation!]! @requiresRole(role: "business_owner") + " the authenticated caller's own business memberships " + myMemberships: [MyBusinessMembership!]! + @requiresAnyRole(roles: ["business_owner", "accountant"]) + } + + " a single business the authenticated caller belongs to, with the role they hold in it " + type MyBusinessMembership { + " composite identity of the membership: the caller's user id combined with the business id " + id: ID! + businessId: ID! + roleId: String! + businessName: String } extend type Mutation { From a113dde772839591de96af9354820bce6204aed4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 26 Jul 2026 15:22:25 +0000 Subject: [PATCH 17/38] chore(dependencies): updated changesets for modified dependencies --- .changeset/@accounter_server-3941-dependencies.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/@accounter_server-3941-dependencies.md diff --git a/.changeset/@accounter_server-3941-dependencies.md b/.changeset/@accounter_server-3941-dependencies.md new file mode 100644 index 000000000..9fff50847 --- /dev/null +++ b/.changeset/@accounter_server-3941-dependencies.md @@ -0,0 +1,6 @@ +--- +"@accounter/server": patch +--- +dependencies updates: + - Updated dependency [`@opentelemetry/auto-instrumentations-node@0.79.0` ↗︎](https://www.npmjs.com/package/@opentelemetry/auto-instrumentations-node/v/0.79.0) (from `0.78.0`, in `dependencies`) + - Updated dependency [`auth0@6.1.0` ↗︎](https://www.npmjs.com/package/auth0/v/6.1.0) (from `6.0.0`, in `dependencies`) From aa0bd82593e8cc749d626f913b9ea3929566ea66 Mon Sep 17 00:00:00 2001 From: Gil Gardosh Date: Sun, 26 Jul 2026 19:03:43 +0300 Subject: [PATCH 18/38] docs(mcp): resolve business memberships from the server, not token claims (#4046) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(mcp): resolve business memberships from the server, not token claims An Auth0 access token carries identity only (sub/email); it does not contain a user's Accounter business memberships or roles. Those live in the server DB (business_users, keyed by auth0_user_id) and are resolved per request. The blueprint/spec previously assumed memberships arrive as a token claim, which would resolve to an empty scope for every real caller. Correct the docs to describe resolving memberships from the Accounter server by forwarding the caller's bearer token and reading a new `myMemberships` query — much simpler than email-ingestion-gateway's control-plane token, since the MCP holds a real end-user token. - spec.md: rewrite §6.4 (token = identity only), add the resolution mechanism to §7.1, clarify §7.3 narrows server-resolved memberships, and fix the §12.2 phase step wording. - implementation-blueprint.md: insert prerequisite Prompt 08a (server `myMemberships` query) and Prompt 08b (MCP upstream membership source) between prompts 08 and 09 (lettered to avoid renumbering already-branched prompts); reframe Prompt 09 to consume the upstream source; update the workstream/slice/ step-list references and the progress note. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo * docs(mcp): address review comments on membership resolution spec (#4052) - spec.md §6.4: document email as an optional claim (sub plus optional email/email_verified) to match the server's JWT parsing, which coerces a missing email to null - blueprint Prompt 08b: describe token forwarding as a requirement of the new MembershipSource rather than existing behavior (the handler makes no upstream calls yet) - blueprint Prompt 08b: introduce the upstream GraphQL client + helpers (graphql-client.ts, getUpstreamClient, coerceMembership, resolveAuthContext) as part of the prompt instead of assuming they already exist Claude-Session: https://claude.ai/code/session_017MqCVWqb56h8vjw8uyZ4rD Co-authored-by: Claude --------- Co-authored-by: Claude --- docs/mcp/implementation-blueprint.md | 105 +++++++++++++++++++++++++-- docs/mcp/spec.md | 29 ++++++-- 2 files changed, 122 insertions(+), 12 deletions(-) diff --git a/docs/mcp/implementation-blueprint.md b/docs/mcp/implementation-blueprint.md index 39152a21f..2092d5599 100644 --- a/docs/mcp/implementation-blueprint.md +++ b/docs/mcp/implementation-blueprint.md @@ -1,6 +1,12 @@ # Accounter MCP Implementation Blueprint and Incremental Prompt Pack -Status: Draft for execution Related spec: docs/mcp/spec.md Last updated: 2026-07-15 +Status: Draft for execution Related spec: docs/mcp/spec.md Last updated: 2026-07-26 + +> Progress note: Prompts 01–08 are merged into the `mcp-setup` branch. Prompts 08a/08b (server-side +> `myMemberships` query + MCP upstream membership source) are prerequisites for Prompt 09 and are +> inserted below between Prompt 08 and Prompt 09. They correct an earlier assumption that Auth0 +> access tokens carry Accounter business memberships — they do not; memberships are resolved from +> the server. Lettered (08a/08b) to avoid renumbering the later, already-branched prompts. ## 1) How to use this document @@ -54,7 +60,8 @@ Exit condition: 1. Serve protected resource metadata on well-known path. 2. Add standards-compliant 401 challenge with WWW-Authenticate and resource_metadata pointer. 3. Implement bearer token validator using Auth0 issuer/audience/JWKS. -4. Add claim mapping into internal auth identity model. +4. Resolve business memberships from the server (forward the caller's token) and map identity + + memberships into the internal auth context. Exit condition: @@ -176,6 +183,8 @@ This decomposition splits each epic into slices that can usually be done in a si ### Slice S2-8: Auth0 token verifier module +### Slice S2-8b: server-resolved memberships (server `myMemberships` query + MCP upstream source) + ### Slice S2-9: identity mapping and auth context ### Slice S2-10: tool registry abstraction and schema contracts @@ -226,7 +235,8 @@ low-risk implementation and review while still moving the project forward. pointer. 12. Add bearer token extraction and validation middleware boundary. 13. Implement Auth0 JWKS token verification module. -14. Map token claims to internal authenticated user context. +14. Resolve business memberships from the server (forward the caller's token to a `myMemberships` + query) and map identity + memberships into the internal authenticated user context. 15. Add auth guard to MCP tool execution path. 16. Implement tool registry type contracts (name, schema, auth policy, handler). 17. Implement input schema validator adapter for tool invocations. @@ -462,22 +472,105 @@ Validation: - Unit tests: valid token accepted, wrong issuer/audience rejected, expired token rejected. ``` +## Prompt 08a - Server: expose the caller's own business memberships + +```text +You are implementing Prompt 08a. + +Context: +An Auth0 access token carries identity only (sub/email); it does NOT contain the user's Accounter +business memberships or roles. Those live in the server DB (accounter_schema.business_users, keyed +by auth0_user_id) and are already resolved per request by the server's auth context. The MCP +connector needs to read a user's businesses BEFORE any business scope is selected, so a +role-/scope-gated query cannot be used. + +Goal: +Add a GraphQL query that returns the authenticated caller's own business memberships. + +Requirements: +- Add `myMemberships: [BusinessMembership!]!` to the auth module typeDefs, gated with @requiresAuth + only (NOT @requiresRole, NOT dependent on a selected business scope). +- Add a `BusinessMembership` GraphQL type: `{ businessId: ID!, roleId: String!, businessName: String }` + (verify the name is free; prefix if it collides). +- Resolver returns the memberships already resolved on the request auth context — reuse + `AuthContextProvider.getAuthContext()` (packages/server/src/modules/auth/providers/ + auth-context.provider.ts) and the `BusinessMembership` shape from + packages/server/src/shared/types/auth.ts. No new DB query needed. +- Register the resolver in the auth module; run `yarn generate`. + +Constraints: +- Read-only; no scope side effects. +- An authenticated user with no memberships returns an empty list, not an error. + +Validation: +- Resolver unit test: multi-business user returns all memberships; user with none returns []. +``` + +## Prompt 08b - MCP: resolve memberships from the server (upstream membership source) + +```text +You are implementing Prompt 08b. + +Context: +This is the MCP's first upstream call to the Accounter GraphQL server: the current handler performs +no upstream GraphQL calls. So the new MembershipSource must forward the caller's bearer token itself +(setting Authorization from the request context on the upstream call) — that forwarding is a +requirement of this prompt, not existing behavior. Once the token is forwarded, resolving +memberships is just a normal authenticated query — no Auth0 custom claim and no shared service +secret (unlike email-ingestion-gateway, which is unattended and uses an X-Gateway-CP-Token). + +Goal: +Resolve business memberships by calling the server's `myMemberships` query with the forwarded token, +and make that the wired membership source. + +Requirements: +- Introduce a minimal upstream GraphQL client as part of this prompt — do not assume one exists yet. + Create packages/mcp-server/src/upstream/graphql-client.ts exposing an UpstreamGraphQLClient, a + createReadOperation helper, and a getUpstreamClient() accessor. Keep it small; Prompt 12 later + generalizes and hardens this client with timeout/retry guardrails. +- Implement an upstream MembershipSource that issues `myMemberships` through that client; map the + result to the internal BusinessMembership shape via a coerceMembership helper (add it, e.g. in + auth/identity.ts). +- Thread the caller's Authorization header + correlation id into the source, and wire it into the + auth-context resolution in packages/mcp-server/src/mcp/handler.ts — introduce a resolveAuthContext + seam if one does not exist yet — replacing the default claims source. +- Error semantics: throw on upstream/auth/infra failure (so the request surfaces 401/5xx, not a + silent empty scope); return [] only when the server legitimately reports no memberships. + +Constraints: +- Keep the MembershipSource seam pluggable; keep membershipsFromClaims exported for tests. +- Never log the token. + +Precheck: +- The forwarded token authenticates to the server only if the server accepts the MCP's Auth0 + audience. Verify a real user token against an existing @requiresAuth server query first. If the + audiences differ, add a token-acquisition step (shared audience or Auth0 token-exchange) before + wiring. + +Validation: +- Unit tests: success maps memberships; upstream error throws; empty server result maps to []. +``` + ## Prompt 09 - Identity mapping to business scope ```text You are implementing Prompt 09. Goal: -Map verified token identity to internal user + business membership context. +Assemble the internal user + business membership auth context from the memberships resolved by the +upstream MembershipSource (Prompt 08b). Requirements: -- Build auth context object containing user id, roles, authorized memberships, and default read scope. -- Reuse existing server-side identity/membership assumptions where possible. +- Build auth context object containing user id, roles, authorized memberships (from the upstream + source), and default read scope. +- Reuse the server-side identity/membership model (mirror the shapes in + packages/server/src/shared/types/auth.ts and shared/helpers/auth-scope.ts). - Add a clear failure mode when mapping cannot resolve a valid user. Constraints: - Keep phase 1 read-only semantics. - No write-target resolution needed yet. +- Do NOT read memberships from token claims (they are not present there — see Prompt 08a/08b). Validation: - Unit/integration tests for user with multiple businesses and narrowed scope behavior. diff --git a/docs/mcp/spec.md b/docs/mcp/spec.md index a63efdf31..89e24f245 100644 --- a/docs/mcp/spec.md +++ b/docs/mcp/spec.md @@ -136,11 +136,18 @@ If Claude Code support is added later, also support loopback callback patterns a ## 6.4 Token and Scope Model -Access token claims must map to: +The Auth0 access token conveys **identity only** — the `sub` (stable user id), plus `email` and +`email_verified` when present (the `email` claim is optional and may be absent). It does **not** +carry the user's Accounter business memberships or roles: those live in the Accounter server's +database and are resolved at request time (see §7.1). -- user identity -- tenant/business memberships -- role claims or resolvable roles from server +The access token is used to: + +- authenticate the caller (identity: `sub`, plus optional `email`/`email_verified`) +- carry coarse OAuth transport scopes (not fine-grained business capability) + +Tenant/business memberships and roles are **not** read from token claims — they are resolved from +the Accounter server by forwarding the caller's bearer token. Scope strategy: @@ -164,6 +171,14 @@ Authorization source of truth remains existing server logic: - business membership checks - read/write scope resolution helpers +The connector never derives memberships from token claims. It resolves the caller's businesses by +**forwarding the user's bearer token** to the Accounter GraphQL server and reading them back from a +dedicated `myMemberships` query (the server maps the Auth0 `sub`/verified email to `business_users` +at request time). Because the connector holds a real end-user token, "authenticating to the server" +is just token pass-through — no Auth0 custom claim and no shared service secret. (Contrast the +unattended `email-ingestion-gateway`, which has no user identity and therefore authenticates to the +server with an `X-Gateway-CP-Token` control-plane credential.) + ## 7.2 Tool-Level Access Policy Each tool must define: @@ -181,7 +196,8 @@ Phase 1 policy: ## 7.3 Scope Narrowing -Support explicit scope narrowing input (when needed), but only as subset of authorized memberships. +Support explicit scope narrowing input (when needed), but only as a subset of the authorized +memberships resolved from the server (§7.1). Rules: @@ -373,7 +389,8 @@ Deliverable: 1. Implement well-known protected resource metadata route. 2. Implement 401 challenge behavior with resource_metadata pointer. 3. Wire Auth0 token validation with PKCE-compatible expectations. -4. Validate claim-to-user mapping against existing auth/user model. +4. Validate token-identity-to-user mapping and server-resolved memberships against the existing + auth/user model. Deliverable: From 817500332e372dd4a3bb6d2a2401562b9fbd9dbb Mon Sep 17 00:00:00 2001 From: Gil Gardosh Date: Sun, 26 Jul 2026 19:23:45 +0300 Subject: [PATCH 19/38] feat(mcp-server): identity mapping to business scope (MCP Prompt 09) (#4010) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mcp-server): map verified identity to business membership context (Prompt 09) Build an internal auth context from a verified token, mirroring the server's tenant-isolation model (docs/mcp/spec.md §7). - src/auth/identity.ts: McpAuthContext (userId, roles, memberships, defaultReadScope); readScopeFromMemberships + narrowReadScope + resolveRequestedReadScope (subset-only narrowing, reject out-of-scope); pluggable MembershipSource defaulting to a `memberships` token claim; IdentityMappingError for an unresolvable user; per-request context store - handler: resolve and store the auth context after token verification - unit tests for multi-business mapping, default/narrowed scope, claim parsing, and the missing-subject failure mode Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo * fix(mcp-server): harden membership claim coercion Address review: exclude arrays from object membership entries and only accept a string/number roleId (never stringify arbitrary objects/arrays). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo * fix(mcp-server): reject malformed membership roles; 401 on identity mapping failure Address PR review feedback on Prompt 09: - coerceMembership now rejects a membership entry whose role is present but of a non-primitive type (object/array/boolean) instead of coercing it to '' — a malformed role could otherwise still contribute its businessId to the derived read scope. A missing role is still allowed (empty role). - The MCP handler now treats IdentityMappingError (e.g. a verified token with no subject) as an invalid_token 401 rather than letting it surface as a 5xx; genuine infrastructure errors still propagate. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo --------- Co-authored-by: Claude --- packages/mcp-server/README.md | 17 +- .../src/auth/__tests__/identity.test.ts | 165 +++++++++++++ packages/mcp-server/src/auth/identity.ts | 223 ++++++++++++++++++ .../src/mcp/__tests__/handler.test.ts | 21 ++ packages/mcp-server/src/mcp/handler.ts | 14 +- 5 files changed, 433 insertions(+), 7 deletions(-) create mode 100644 packages/mcp-server/src/auth/__tests__/identity.test.ts create mode 100644 packages/mcp-server/src/auth/identity.ts diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 979d64111..b14d951cd 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -15,9 +15,20 @@ Early scaffolding. This package is being built incrementally following the promp implementation blueprint. It currently contains the package skeleton, strict environment configuration, a minimal HTTP server with a `/health` endpoint and graceful shutdown, an MCP transport route (`POST /mcp`) that speaks JSON-RPC 2.0 and lists an internal smoke tool, per-request -structured logging with request/correlation ids, the OAuth protected-resource metadata endpoint, and -Auth0 bearer-token verification on the MCP endpoint. Fine-grained authorization and production tools -are not implemented yet. +structured logging with request/correlation ids, the OAuth protected-resource metadata endpoint, +Auth0 bearer-token verification, and identity mapping from a verified token to an internal user + +business-membership context. Fine-grained authorization and production tools are not implemented +yet. + +## Identity & tenant scope + +A verified token is mapped to an `McpAuthContext` — `userId`, `roles` (token scopes), business +`memberships`, and a `defaultReadScope` (every business the user belongs to). Memberships are read +from the token's `memberships` custom claim by default (the source is pluggable so a later step can +resolve them from the GraphQL upstream instead). Requested scope narrowing is validated against the +user's memberships: any business id outside them is rejected rather than silently dropped. These +shapes and rules mirror the server package's tenant-isolation model +(`packages/server/src/shared/helpers/auth-scope.ts`). ## OAuth discovery diff --git a/packages/mcp-server/src/auth/__tests__/identity.test.ts b/packages/mcp-server/src/auth/__tests__/identity.test.ts new file mode 100644 index 000000000..ac0122e92 --- /dev/null +++ b/packages/mcp-server/src/auth/__tests__/identity.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from 'vitest'; +import type { AuthPrincipal } from '../token.js'; +import { + buildAuthContext, + dedupeMemberships, + IdentityMappingError, + membershipsFromClaims, + narrowReadScope, + readScopeFromMemberships, + resolveAuthContext, + resolveRequestedReadScope, +} from '../identity.js'; + +function principal(overrides: Partial = {}): AuthPrincipal { + return { + subject: 'user-1', + issuer: 'https://tenant.auth0.com/', + audience: 'aud', + scopes: ['read:charges'], + email: 'a@b.com', + expiresAt: undefined, + claims: { sub: 'user-1' }, + ...overrides, + }; +} + +const M = (businessId: string, roleId = 'accountant') => ({ businessId, roleId }); + +describe('readScopeFromMemberships', () => { + it('is every business, de-duplicated and order-preserving', () => { + expect(readScopeFromMemberships([M('b1'), M('b2'), M('b1')])).toEqual({ + businessIds: ['b1', 'b2'], + }); + }); +}); + +describe('narrowReadScope', () => { + const memberships = [M('b1'), M('b2'), M('b3')]; + + it('returns the requested subset (de-duplicated, order preserved)', () => { + expect(narrowReadScope(memberships, ['b2', 'b1', 'b2'])).toEqual({ businessIds: ['b2', 'b1'] }); + }); + + it('returns null when any requested id is outside memberships', () => { + expect(narrowReadScope(memberships, ['b2', 'bX'])).toBeNull(); + }); +}); + +describe('dedupeMemberships', () => { + it('keeps the first occurrence per business id', () => { + expect(dedupeMemberships([M('b1', 'r1'), M('b1', 'r2'), M('b2', 'r3')])).toEqual([ + M('b1', 'r1'), + M('b2', 'r3'), + ]); + }); +}); + +describe('buildAuthContext', () => { + it('maps a user with multiple businesses to context + default scope', () => { + const ctx = buildAuthContext(principal(), [M('b1'), M('b2')]); + expect(ctx.userId).toBe('user-1'); + expect(ctx.auth0UserId).toBe('user-1'); + expect(ctx.email).toBe('a@b.com'); + expect(ctx.roles).toEqual(['read:charges']); + expect(ctx.memberships).toEqual([M('b1'), M('b2')]); + expect(ctx.defaultReadScope).toEqual({ businessIds: ['b1', 'b2'] }); + }); + + it('accepts a valid user with no memberships (empty scope)', () => { + const ctx = buildAuthContext(principal(), []); + expect(ctx.memberships).toEqual([]); + expect(ctx.defaultReadScope).toEqual({ businessIds: [] }); + }); + + it('throws IdentityMappingError when the subject is missing', () => { + expect(() => buildAuthContext(principal({ subject: '' }), [M('b1')])).toThrow( + IdentityMappingError, + ); + }); +}); + +describe('resolveRequestedReadScope', () => { + const ctx = buildAuthContext(principal(), [M('b1'), M('b2'), M('b3')]); + + it('defaults to all memberships when nothing is requested', () => { + expect(resolveRequestedReadScope(ctx)).toEqual({ businessIds: ['b1', 'b2', 'b3'] }); + expect(resolveRequestedReadScope(ctx, [])).toEqual({ businessIds: ['b1', 'b2', 'b3'] }); + }); + + it('narrows to a requested subset', () => { + expect(resolveRequestedReadScope(ctx, ['b3', 'b1'])).toEqual({ businessIds: ['b3', 'b1'] }); + }); + + it('returns null for a requested id outside the memberships', () => { + expect(resolveRequestedReadScope(ctx, ['b1', 'bX'])).toBeNull(); + }); +}); + +describe('membershipsFromClaims', () => { + it('parses camelCase and snake_case entries and de-duplicates', () => { + const p = principal({ + claims: { + sub: 'user-1', + memberships: [ + { businessId: 'b1', roleId: 'admin' }, + { business_id: 'b2', role_id: 'accountant' }, + { businessId: 'b1', roleId: 'ignored-duplicate' }, + ], + }, + }); + expect(membershipsFromClaims(p)).toEqual([M('b1', 'admin'), M('b2', 'accountant')]); + }); + + it('ignores malformed entries and a non-array claim', () => { + expect(membershipsFromClaims(principal({ claims: { sub: 'u', memberships: 'nope' } }))).toEqual( + [], + ); + expect( + membershipsFromClaims( + principal({ + claims: { sub: 'u', memberships: [null, {}, { roleId: 'x' }, 42, ['b1', 'r1']] }, + }), + ), + ).toEqual([]); + }); + + it('coerces a numeric roleId but rejects entries with object/array roles', () => { + // A present-but-non-primitive role marks a malformed entry: it is dropped + // entirely rather than coerced to '', so its businessId cannot slip into the + // derived read scope. A missing role is still allowed (empty role). + expect( + membershipsFromClaims( + principal({ + claims: { + sub: 'u', + memberships: [ + { businessId: 'b1', roleId: 7 }, + { businessId: 'b2', roleId: { nested: true } }, + { businessId: 'b3', roleId: ['array-role'] }, + { businessId: 'b4' }, + ], + }, + }), + ), + ).toEqual([M('b1', '7'), M('b4', '')]); + }); +}); + +describe('resolveAuthContext', () => { + it('reads memberships from the token claim by default', async () => { + const p = principal({ + claims: { sub: 'user-1', memberships: [{ businessId: 'b1', roleId: 'admin' }] }, + }); + const ctx = await resolveAuthContext(p); + expect(ctx.memberships).toEqual([M('b1', 'admin')]); + expect(ctx.defaultReadScope).toEqual({ businessIds: ['b1'] }); + }); + + it('supports an injected async membership source (multi-business)', async () => { + const ctx = await resolveAuthContext(principal(), async () => [M('b1'), M('b2')]); + expect(ctx.defaultReadScope).toEqual({ businessIds: ['b1', 'b2'] }); + // and narrowing works against the resolved memberships + expect(resolveRequestedReadScope(ctx, ['b2'])).toEqual({ businessIds: ['b2'] }); + }); +}); diff --git a/packages/mcp-server/src/auth/identity.ts b/packages/mcp-server/src/auth/identity.ts new file mode 100644 index 000000000..bb53184e7 --- /dev/null +++ b/packages/mcp-server/src/auth/identity.ts @@ -0,0 +1,223 @@ +import type { IncomingMessage } from 'node:http'; +import type { AuthPrincipal } from './token.js'; + +/** + * Identity mapping: verified token → internal user + business membership context. + * + * The membership / read-scope shapes and rules mirror the server package + * (`packages/server/src/shared/types/auth.ts` and `shared/helpers/auth-scope.ts`) + * so the connector enforces the same tenant-isolation model. They are mirrored + * rather than imported to keep this package standalone; a shared `@accounter/auth` + * package would let both consume one source of truth. + * + * Phase 1 is read-only: no write-target resolution is performed here. + */ + +/** A single business the user belongs to, with the role they hold in it. */ +export interface BusinessMembership { + businessId: string; + roleId: string; +} + +/** The set of businesses a request is authorized to read from. */ +export interface AuthorizedReadScope { + businessIds: string[]; +} + +/** Internal auth context derived from a verified access token. */ +export interface McpAuthContext { + /** Stable user id (token `sub`). */ + userId: string; + /** Auth0 user id (same as `sub` for Auth0-issued tokens). */ + auth0UserId: string; + email: string | null; + /** Granted roles/scopes from the token. */ + roles: readonly string[]; + /** All businesses the user belongs to. */ + memberships: readonly BusinessMembership[]; + /** Default read scope = every business the user belongs to. */ + defaultReadScope: AuthorizedReadScope; + /** The verified principal this context was built from. */ + principal: AuthPrincipal; +} + +/** Raised when a verified token cannot be mapped to a valid user. */ +export class IdentityMappingError extends Error { + public readonly code = 'identity_unresolved'; + + constructor(message: string) { + super(message); + this.name = 'IdentityMappingError'; + } +} + +/** + * Default read scope = every business the user belongs to, de-duplicated and + * order-preserving. Mirrors the server's `readScopeFromMemberships`. + */ +export function readScopeFromMemberships( + memberships: readonly BusinessMembership[], +): AuthorizedReadScope { + return { businessIds: [...new Set(memberships.map(m => m.businessId))] }; +} + +/** + * Narrow a user's memberships to a requested set of business ids. Returns the + * requested ids (de-duplicated, request order preserved) as the read scope, or + * `null` if ANY requested id is outside the user's memberships — callers must + * reject rather than silently dropping unknown ids. Mirrors the server's + * `narrowReadScope`. + */ +export function narrowReadScope( + memberships: readonly BusinessMembership[], + requestedBusinessIds: readonly string[], +): AuthorizedReadScope | null { + const allowed = new Set(memberships.map(m => m.businessId)); + const seen = new Set(); + const businessIds: string[] = []; + for (const businessId of requestedBusinessIds) { + if (!allowed.has(businessId)) { + return null; + } + if (!seen.has(businessId)) { + seen.add(businessId); + businessIds.push(businessId); + } + } + return { businessIds }; +} + +/** + * Resolve the effective read scope for a request: no requested ids ⇒ the + * default (all memberships); otherwise the requested subset, or `null` when any + * requested id falls outside the user's memberships. + */ +export function resolveRequestedReadScope( + context: McpAuthContext, + requestedBusinessIds?: readonly string[], +): AuthorizedReadScope | null { + if (!requestedBusinessIds || requestedBusinessIds.length === 0) { + return context.defaultReadScope; + } + return narrowReadScope(context.memberships, requestedBusinessIds); +} + +/** + * Custom claim carrying the user's business memberships. Auth0 must be + * configured to emit it (via a login/enrichment action). Entries accept either + * camelCase or snake_case keys. + */ +export const MEMBERSHIPS_CLAIM = 'memberships'; + +/** A function that resolves a principal's memberships (claims, upstream, …). */ +export type MembershipSource = ( + principal: AuthPrincipal, +) => readonly BusinessMembership[] | Promise; + +function coerceMembership(entry: unknown): BusinessMembership | null { + // Arrays are objects too; exclude them and any non-object claim entries. + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + return null; + } + const record = entry as Record; + const businessId = record.businessId ?? record.business_id; + if (typeof businessId !== 'string' || businessId.length === 0) { + return null; + } + // The role may be absent (treated as an empty role). But a *present* role of a + // non-primitive type (object/array/boolean) marks a malformed entry: reject it + // outright rather than coercing to '' and letting a bad claim still contribute + // its businessId to the derived read scope. + const rawRoleId = record.roleId ?? record.role_id; + let roleId: string; + if (rawRoleId === undefined || rawRoleId === null) { + roleId = ''; + } else if (typeof rawRoleId === 'string') { + roleId = rawRoleId; + } else if (typeof rawRoleId === 'number') { + roleId = String(rawRoleId); + } else { + return null; + } + return { businessId, roleId }; +} + +/** De-duplicate memberships by business id (first occurrence wins). */ +export function dedupeMemberships( + memberships: readonly BusinessMembership[], +): BusinessMembership[] { + const seen = new Set(); + const result: BusinessMembership[] = []; + for (const membership of memberships) { + if (!seen.has(membership.businessId)) { + seen.add(membership.businessId); + result.push(membership); + } + } + return result; +} + +/** Default membership source: read the `memberships` custom claim off the token. */ +export const membershipsFromClaims: MembershipSource = principal => { + const raw = principal.claims[MEMBERSHIPS_CLAIM]; + if (!Array.isArray(raw)) { + return []; + } + const memberships: BusinessMembership[] = []; + for (const entry of raw) { + const membership = coerceMembership(entry); + if (membership) { + memberships.push(membership); + } + } + return dedupeMemberships(memberships); +}; + +/** + * Assemble an {@link McpAuthContext} from a verified principal and its resolved + * memberships. Throws {@link IdentityMappingError} when the principal has no + * subject (cannot identify a user). An empty membership set is a valid user + * with no business access — authorization (later step) decides what that user + * may do. + */ +export function buildAuthContext( + principal: AuthPrincipal, + memberships: readonly BusinessMembership[], +): McpAuthContext { + if (!principal.subject) { + throw new IdentityMappingError('verified token has no subject claim'); + } + const deduped = dedupeMemberships(memberships); + return { + userId: principal.subject, + auth0UserId: principal.subject, + email: principal.email, + roles: principal.scopes, + memberships: deduped, + defaultReadScope: readScopeFromMemberships(deduped), + principal, + }; +} + +/** + * Resolve the full auth context for a verified principal using the given + * membership source (defaults to reading the token's `memberships` claim). + */ +export async function resolveAuthContext( + principal: AuthPrincipal, + source: MembershipSource = membershipsFromClaims, +): Promise { + const memberships = await source(principal); + return buildAuthContext(principal, memberships); +} + +// Associate a resolved auth context with its request for downstream steps. +const contextByRequest = new WeakMap(); + +export function setAuthContext(req: IncomingMessage, context: McpAuthContext): void { + contextByRequest.set(req, context); +} + +export function getAuthContext(req: IncomingMessage): McpAuthContext | undefined { + return contextByRequest.get(req); +} diff --git a/packages/mcp-server/src/mcp/__tests__/handler.test.ts b/packages/mcp-server/src/mcp/__tests__/handler.test.ts index aea4b29e6..5bb2f2c5c 100644 --- a/packages/mcp-server/src/mcp/__tests__/handler.test.ts +++ b/packages/mcp-server/src/mcp/__tests__/handler.test.ts @@ -191,6 +191,27 @@ describe('mcpHttpHandler', () => { resetEnvCache(); }); + it('challenges with 401 + error="invalid_token" when identity mapping fails', async () => { + vi.stubEnv('MCP_PUBLIC_BASE_URL', 'https://mcp.example.com'); + vi.stubEnv('AUTH0_ISSUER_URL', 'https://tenant.auth0.com/'); + vi.stubEnv('AUTH0_AUDIENCE', 'aud'); + vi.stubEnv('GRAPHQL_UPSTREAM_URL', 'http://localhost:4000/graphql'); + const { resetEnvCache } = await import('../../config/env.js'); + resetEnvCache(); + // A verified token with no subject cannot be mapped to a user, so + // resolveAuthContext throws IdentityMappingError — a 401, not a 5xx. + mockVerify.mockResolvedValue({ ...PRINCIPAL, subject: '', claims: {} }); + + const res = mockRes(); + await mcpHttpHandler(mockReq(rpc('tools/list')), res); + + expect(res.writeHead).toHaveBeenCalledWith(401, { 'Content-Type': 'application/json' }); + const wwwAuth = res.setHeader.mock.calls.find(([name]) => name === 'WWW-Authenticate'); + expect(wwwAuth?.[1]).toContain('error="invalid_token"'); + vi.unstubAllEnvs(); + resetEnvCache(); + }); + it('propagates infrastructure errors instead of returning a misleading 401', async () => { // An error without a token-validation code (e.g. a JWKS outage) must bubble // up so the request becomes a 5xx, not a 401. diff --git a/packages/mcp-server/src/mcp/handler.ts b/packages/mcp-server/src/mcp/handler.ts index 7d900fc45..52568d0fb 100644 --- a/packages/mcp-server/src/mcp/handler.ts +++ b/packages/mcp-server/src/mcp/handler.ts @@ -1,4 +1,5 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; +import { IdentityMappingError, resolveAuthContext, setAuthContext } from '../auth/identity.js'; import { extractBearerToken, setAuthPrincipal, @@ -168,12 +169,17 @@ async function authenticate( try { const principal = await verifyAccessToken(token); setAuthPrincipal(req, principal); + // Map the verified identity to internal user + business membership context. + // An empty membership set is a valid user with no access; per-tool policy + // (a later step) decides what that permits. + setAuthContext(req, await resolveAuthContext(principal)); return principal; } catch (error) { - // Only an invalid token is a 401; infrastructure failures (e.g. a JWKS - // outage) propagate so the request surfaces as a 5xx rather than a - // misleading auth error. - if (!(error instanceof TokenVerificationError)) { + // An invalid token, or a verified token that cannot be mapped to a usable + // identity (e.g. a missing subject claim), is a 401. Infrastructure failures + // (e.g. a JWKS outage) propagate so the request surfaces as a 5xx rather than + // a misleading auth error. + if (!(error instanceof TokenVerificationError) && !(error instanceof IdentityMappingError)) { throw error; } // Log the reason only — never the token. From 2a0fd686eeb9311496ffc31d13ec93a90bb3c7c9 Mon Sep 17 00:00:00 2001 From: Gil Gardosh Date: Sun, 26 Jul 2026 19:36:44 +0300 Subject: [PATCH 20/38] feat(mcp-server): curated tool registry & input validation (MCP Prompt 10) (#4011) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mcp-server): curated tool registry and input validation (Prompt 10) Add the production-ready tool registry abstraction (docs/mcp/spec.md §8.3). - src/tools/registry.ts: ToolDefinition (name, description, strict zod input schema, auth policy, pure handler); ToolRegistry with register/get/has/list and duplicate-name rejection; describe() renders input schemas to JSON Schema (additionalProperties:false) for tools/list - validateToolInput(): strict parsing with unknown-field rejection and a deterministic VALIDATION_ERROR payload (code, message, field-path issues) - ToolAuthPolicy / DataClassification / ToolExecutionContext contracts for the policy evaluator and handlers in later steps - unit tests for registration, duplicates, ordering, describe(), and schema validation (valid, missing required, unknown field, type mismatch) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo * fix(mcp-server): honor MCP kill-switch; harden tools/call params & schema strictness Address PR review feedback on Prompt 10: - MCP_ENABLED=0 now actually disables the connector: the MCP transport (`/mcp`) and its OAuth protected-resource-metadata discovery route return 404 (health only), instead of being served while the logs claim "disabled mode". - `tools/call` returns a clear InvalidParams ("params must be an object") when `params` is an array or other non-object, instead of a misleading "Unknown tool: undefined". - `registry.describe()` renders JSON Schema from the same `.strict()` schema that `validateToolInput` enforces, so the advertised `additionalProperties: false` provably matches the runtime unknown-field rejection. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo --------- Co-authored-by: Claude --- .../mcp-server/src/__tests__/server.test.ts | 41 +++++ .../src/mcp/__tests__/handler.test.ts | 6 + packages/mcp-server/src/mcp/handler.ts | 12 +- packages/mcp-server/src/server.ts | 8 +- .../src/tools/__tests__/registry.test.ts | 95 ++++++++++ packages/mcp-server/src/tools/registry.ts | 172 ++++++++++++++++++ 6 files changed, 332 insertions(+), 2 deletions(-) create mode 100644 packages/mcp-server/src/tools/__tests__/registry.test.ts create mode 100644 packages/mcp-server/src/tools/registry.ts diff --git a/packages/mcp-server/src/__tests__/server.test.ts b/packages/mcp-server/src/__tests__/server.test.ts index af2233e77..1680bd2ac 100644 --- a/packages/mcp-server/src/__tests__/server.test.ts +++ b/packages/mcp-server/src/__tests__/server.test.ts @@ -94,6 +94,47 @@ describe('GET /.well-known/oauth-protected-resource', () => { }); }); +describe('MCP kill-switch (MCP_ENABLED=0)', () => { + async function withMcpDisabled(run: () => Promise) { + vi.stubEnv('MCP_PUBLIC_BASE_URL', 'https://mcp.example.com'); + vi.stubEnv('AUTH0_ISSUER_URL', 'https://tenant.auth0.com/'); + vi.stubEnv('AUTH0_AUDIENCE', 'aud'); + vi.stubEnv('GRAPHQL_UPSTREAM_URL', 'http://localhost:4000/graphql'); + vi.stubEnv('MCP_ENABLED', '0'); + const { resetEnvCache } = await import('../config/env.js'); + resetEnvCache(); + try { + await run(); + } finally { + vi.unstubAllEnvs(); + resetEnvCache(); + } + } + + it('serves health but 404s the MCP transport and its metadata route', async () => { + await withMcpDisabled(async () => { + const health = mockRes(); + await requestHandler(mockReq({ method: 'GET', url: '/health' }), health); + expect(health.writeHead).toHaveBeenCalledWith(200, { 'Content-Type': 'application/json' }); + + const mcpGet = mockRes(); + await requestHandler(mockReq({ method: 'GET', url: '/mcp' }), mcpGet); + expect(mcpGet.writeHead).toHaveBeenCalledWith(404, { 'Content-Type': 'application/json' }); + + const mcpPost = mockRes(); + await requestHandler(mockReq({ method: 'POST', url: '/mcp' }), mcpPost); + expect(mcpPost.writeHead).toHaveBeenCalledWith(404, { 'Content-Type': 'application/json' }); + + const meta = mockRes(); + await requestHandler( + mockReq({ method: 'GET', url: PROTECTED_RESOURCE_METADATA_PATH }), + meta, + ); + expect(meta.writeHead).toHaveBeenCalledWith(404, { 'Content-Type': 'application/json' }); + }); + }); +}); + describe('requestHandler routing', () => { it('returns 404 for unknown paths', async () => { const res = mockRes(); diff --git a/packages/mcp-server/src/mcp/__tests__/handler.test.ts b/packages/mcp-server/src/mcp/__tests__/handler.test.ts index 5bb2f2c5c..5a9dd4e26 100644 --- a/packages/mcp-server/src/mcp/__tests__/handler.test.ts +++ b/packages/mcp-server/src/mcp/__tests__/handler.test.ts @@ -60,6 +60,12 @@ describe('handleMcpBody — method dispatch', () => { expect(res.error.code).toBe(JsonRpcErrorCode.InvalidParams); }); + it('returns InvalidParams (not "Unknown tool") when tools/call params is an array', () => { + const res = handleMcpBody(rpc('tools/call', ['not', 'an', 'object'])) as JsonRpcErrorResponse; + expect(res.error.code).toBe(JsonRpcErrorCode.InvalidParams); + expect(res.error.message).toContain('must be an object'); + }); + it('returns MethodNotFound for an unsupported method', () => { const res = handleMcpBody(rpc('resources/list')) as JsonRpcErrorResponse; expect(res.error.code).toBe(JsonRpcErrorCode.MethodNotFound); diff --git a/packages/mcp-server/src/mcp/handler.ts b/packages/mcp-server/src/mcp/handler.ts index 52568d0fb..2ef7caedb 100644 --- a/packages/mcp-server/src/mcp/handler.ts +++ b/packages/mcp-server/src/mcp/handler.ts @@ -74,7 +74,17 @@ export function handleRpcRequest(request: JsonRpcRequest): JsonRpcResponse | nul return success(id, { tools: listedTools }); case 'tools/call': { - const params = (request.params ?? {}) as { name?: unknown; arguments?: unknown }; + // Params, when present, must be a JSON object. An array (which + // asJsonRpcRequest permits) or other non-object shape is a malformed + // params error, not a mislabeled "Unknown tool: undefined". + const rawParams = request.params; + if ( + rawParams !== undefined && + (typeof rawParams !== 'object' || rawParams === null || Array.isArray(rawParams)) + ) { + return failure(id, JsonRpcErrorCode.InvalidParams, 'tools/call params must be an object'); + } + const params = (rawParams ?? {}) as { name?: unknown; arguments?: unknown }; if (params.name === SMOKE_TOOL_NAME) { return success(id, runSmokeTool(params.arguments)); } diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index e86d30164..43cc04b67 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -83,7 +83,13 @@ export async function requestHandler(req: IncomingMessage, res: ServerResponse): }); try { - const handler = routes[context.method]?.[context.route]; + // Kill-switch: when MCP is disabled the server serves health only, so the + // MCP transport and its OAuth resource-metadata discovery route are treated + // as absent (404) rather than being advertised/served. + const isMcpRoute = + context.route === MCP_ROUTE_PATH || context.route === PROTECTED_RESOURCE_METADATA_PATH; + const handler = + isMcpRoute && !env.server.enabled ? undefined : routes[context.method]?.[context.route]; if (handler) { await handler(req, res); } else { diff --git a/packages/mcp-server/src/tools/__tests__/registry.test.ts b/packages/mcp-server/src/tools/__tests__/registry.test.ts new file mode 100644 index 000000000..793cb7b00 --- /dev/null +++ b/packages/mcp-server/src/tools/__tests__/registry.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { + DuplicateToolError, + type ToolDefinition, + ToolRegistry, + validateToolInput, +} from '../registry.js'; + +function makeTool(name: string): ToolDefinition { + return { + name, + description: `desc for ${name}`, + inputSchema: z.object({ query: z.string(), limit: z.number().int().optional() }), + policy: { requiresBusinessScope: true, dataClassification: 'business' }, + handler: input => ({ content: [{ type: 'text', text: JSON.stringify(input) }] }), + }; +} + +describe('ToolRegistry', () => { + it('registers and looks up a tool', () => { + const registry = new ToolRegistry(); + const tool = makeTool('charges_search'); + registry.register(tool); + + expect(registry.has('charges_search')).toBe(true); + expect(registry.get('charges_search')).toBe(tool); + expect(registry.get('missing')).toBeUndefined(); + }); + + it('rejects duplicate names', () => { + const registry = new ToolRegistry(); + registry.register(makeTool('dup')); + expect(() => registry.register(makeTool('dup'))).toThrow(DuplicateToolError); + }); + + it('preserves registration order in list()', () => { + const registry = new ToolRegistry(); + registry.register(makeTool('a')); + registry.register(makeTool('b')); + registry.register(makeTool('c')); + expect(registry.list().map(t => t.name)).toEqual(['a', 'b', 'c']); + }); + + it('describes tools with JSON-Schema input (unknown fields disallowed)', () => { + const registry = new ToolRegistry(); + registry.register(makeTool('charges_search')); + const [descriptor] = registry.describe(); + + expect(descriptor.name).toBe('charges_search'); + expect(descriptor.inputSchema.type).toBe('object'); + expect(descriptor.inputSchema.additionalProperties).toBe(false); + expect(descriptor.inputSchema.required).toEqual(['query']); + }); +}); + +describe('validateToolInput', () => { + const tool = makeTool('charges_search'); + + it('accepts valid input and returns typed data', () => { + const result = validateToolInput(tool, { query: 'acme', limit: 10 }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data).toEqual({ query: 'acme', limit: 10 }); + } + }); + + it('defaults missing args to an empty object (still validated)', () => { + const result = validateToolInput(tool, undefined); + // query is required, so an empty object fails deterministically + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe('VALIDATION_ERROR'); + expect(result.error.issues.some(i => i.path === 'query')).toBe(true); + } + }); + + it('rejects unknown fields with a deterministic payload', () => { + const result = validateToolInput(tool, { query: 'x', bogus: true }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe('VALIDATION_ERROR'); + expect(result.error.message).toBe('Invalid tool input'); + expect(result.error.issues.length).toBeGreaterThan(0); + } + }); + + it('reports the field path for a type mismatch', () => { + const result = validateToolInput(tool, { query: 123 }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.issues.some(i => i.path === 'query')).toBe(true); + } + }); +}); diff --git a/packages/mcp-server/src/tools/registry.ts b/packages/mcp-server/src/tools/registry.ts new file mode 100644 index 000000000..d9a56e070 --- /dev/null +++ b/packages/mcp-server/src/tools/registry.ts @@ -0,0 +1,172 @@ +import { z } from 'zod'; +import type { AuthorizedReadScope, McpAuthContext } from '../auth/identity.js'; + +/** + * Curated tool registry abstraction. + * + * Every exposed capability is a {@link ToolDefinition} with a strict input + * schema, an authorization policy, and a pure handler. The registry supports + * incremental registration and lookup, and provides strict input validation + * with unknown-field rejection and a deterministic validation error payload. + * + * Authorization enforcement (the policy) and handler execution are wired in + * later steps; this file defines the contracts and the validation layer. + */ + +/** Data-sensitivity class of a tool's output (spec §9.4). */ +export type DataClassification = 'public' | 'business' | 'sensitive'; + +/** Per-tool authorization policy (spec §7.2). Evaluated before execution. */ +export interface ToolAuthPolicy { + /** Roles/scopes the caller must hold (any-of). Empty/omitted ⇒ no role gate. */ + requiredRoles?: readonly string[]; + /** Whether the tool operates within the caller's business read scope. */ + requiresBusinessScope: boolean; + /** Sensitivity of the data the tool returns. */ + dataClassification: DataClassification; +} + +/** Context passed to a tool handler at execution time. */ +export interface ToolExecutionContext { + /** The authenticated caller and their memberships. */ + auth: McpAuthContext; + /** Read scope resolved for this call (default or caller-narrowed). */ + readScope: AuthorizedReadScope; + /** Correlation id for tracing/log/upstream propagation. */ + correlationId: string; +} + +export interface ToolTextContent { + type: 'text'; + text: string; +} + +/** MCP `tools/call` result. */ +export interface ToolResult { + content: ToolTextContent[]; + isError?: boolean; + /** Optional machine-readable payload mirrored alongside the text content. */ + structuredContent?: unknown; +} + +/** A zod object schema describing a tool's input. */ +export type ToolInputSchema = z.ZodObject; + +/** A registered, curated tool. Handlers must be pure and testable. */ +export interface ToolDefinition { + name: string; + description: string; + inputSchema: Schema; + policy: ToolAuthPolicy; + handler: ( + input: z.infer, + context: ToolExecutionContext, + ) => Promise | ToolResult; +} + +/** Tool descriptor advertised by `tools/list` (JSON Schema for the input). */ +export interface ToolDescriptor { + name: string; + description: string; + inputSchema: Record; +} + +// --------------------------------------------------------------------------- +// Input validation +// --------------------------------------------------------------------------- + +export interface ToolValidationIssue { + /** Dot-joined path to the offending field (empty for the root object). */ + path: string; + message: string; +} + +/** Deterministic validation error payload (spec §10.2 VALIDATION_ERROR). */ +export interface ToolValidationError { + code: 'VALIDATION_ERROR'; + message: string; + issues: ToolValidationIssue[]; +} + +export type ToolInputValidation = + { ok: true; data: T } | { ok: false; error: ToolValidationError }; + +function toValidationError(error: z.ZodError): ToolValidationError { + const issues = error.issues.map(issue => ({ + path: issue.path.map(String).join('.'), + message: issue.message, + })); + return { + code: 'VALIDATION_ERROR', + message: 'Invalid tool input', + issues, + }; +} + +/** + * Validate raw tool-call arguments against a tool's schema. Unknown fields are + * rejected (`.strict()`). Returns the parsed input or a deterministic error. + */ +export function validateToolInput( + tool: ToolDefinition, + rawArgs: unknown, +): ToolInputValidation> { + const result = tool.inputSchema.strict().safeParse(rawArgs ?? {}); + if (result.success) { + return { ok: true, data: result.data as z.infer }; + } + return { ok: false, error: toValidationError(result.error) }; +} + +// --------------------------------------------------------------------------- +// Registry +// --------------------------------------------------------------------------- + +/** Thrown when registering a tool whose name is already taken. */ +export class DuplicateToolError extends Error { + constructor(name: string) { + super(`A tool named "${name}" is already registered`); + this.name = 'DuplicateToolError'; + } +} + +/** + * In-memory registry of curated tools. Registration order is preserved so + * `list()`/`describe()` output is stable. + */ +export class ToolRegistry { + private readonly tools = new Map(); + + /** Register a tool. Throws {@link DuplicateToolError} on a name collision. */ + register(tool: ToolDefinition): void { + if (this.tools.has(tool.name)) { + throw new DuplicateToolError(tool.name); + } + this.tools.set(tool.name, tool); + } + + has(name: string): boolean { + return this.tools.has(name); + } + + get(name: string): ToolDefinition | undefined { + return this.tools.get(name); + } + + /** All registered tools, in registration order. */ + list(): ToolDefinition[] { + return [...this.tools.values()]; + } + + /** Descriptors for `tools/list`, with input schemas rendered to JSON Schema. */ + describe(): ToolDescriptor[] { + return this.list().map(tool => ({ + name: tool.name, + description: tool.description, + // Render from the same `.strict()` schema that `validateToolInput` enforces + // so the advertised JSON Schema (`additionalProperties: false`) matches the + // runtime unknown-field rejection, rather than describing a laxer shape. + inputSchema: z.toJSONSchema(tool.inputSchema.strict()) as Record, + })); + } +} From d1984350bcfcf12caf85754295868cd601d2c9ec Mon Sep 17 00:00:00 2001 From: Gil Gardosh Date: Sun, 26 Jul 2026 19:43:30 +0300 Subject: [PATCH 21/38] feat(mcp-server): per-tool authorization policy evaluator (Prompt 11) (#4012) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enforce required roles and business-scope constraints before tool execution (docs/mcp/spec.md §7). - src/tools/policy.ts: evaluateToolPolicy() checks required roles (any-of) and resolves the request's read scope as a subset of the caller's memberships, denying out-of-scope requests and empty scope (when required) with a deterministic AUTHORIZATION_ERROR; authorizeToolCall() convenience for a registered tool - unit tests for allow, role deny, out-of-scope deny, empty-scope deny, subset narrowing, any-of roles, and no-role-gate Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo Co-authored-by: Claude --- .../src/tools/__tests__/policy.test.ts | 118 ++++++++++++++++++ packages/mcp-server/src/tools/policy.ts | 82 ++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 packages/mcp-server/src/tools/__tests__/policy.test.ts create mode 100644 packages/mcp-server/src/tools/policy.ts diff --git a/packages/mcp-server/src/tools/__tests__/policy.test.ts b/packages/mcp-server/src/tools/__tests__/policy.test.ts new file mode 100644 index 000000000..9a032f27e --- /dev/null +++ b/packages/mcp-server/src/tools/__tests__/policy.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest'; +import { buildAuthContext, type McpAuthContext } from '../../auth/identity.js'; +import type { AuthPrincipal } from '../../auth/token.js'; +import { authorizeToolCall, evaluateToolPolicy } from '../policy.js'; +import type { ToolAuthPolicy, ToolDefinition } from '../registry.js'; + +function authContext( + memberships: Array<{ businessId: string; roleId: string }>, + scopes: 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, memberships); +} + +const M = (businessId: string) => ({ businessId, roleId: 'accountant' }); + +const scopedPolicy: ToolAuthPolicy = { + requiresBusinessScope: true, + dataClassification: 'business', +}; + +describe('evaluateToolPolicy — business scope', () => { + it('allows and defaults to all memberships when no scope is requested', () => { + const decision = evaluateToolPolicy({ + policy: scopedPolicy, + auth: authContext([M('b1'), M('b2')]), + }); + expect(decision).toEqual({ allowed: true, readScope: { businessIds: ['b1', 'b2'] } }); + }); + + it('allows a caller-narrowed subset', () => { + const decision = evaluateToolPolicy({ + policy: scopedPolicy, + auth: authContext([M('b1'), M('b2'), M('b3')]), + requestedBusinessIds: ['b3', 'b1'], + }); + expect(decision).toEqual({ allowed: true, readScope: { businessIds: ['b3', 'b1'] } }); + }); + + it('denies a requested scope outside the memberships', () => { + const decision = evaluateToolPolicy({ + policy: scopedPolicy, + auth: authContext([M('b1')]), + requestedBusinessIds: ['b1', 'bX'], + }); + expect(decision.allowed).toBe(false); + if (!decision.allowed) { + expect(decision.error.code).toBe('AUTHORIZATION_ERROR'); + expect(decision.error.message).toMatch(/outside your authorized memberships/); + } + }); + + it('denies when the tool requires business scope but the caller has none', () => { + const decision = evaluateToolPolicy({ policy: scopedPolicy, auth: authContext([]) }); + expect(decision.allowed).toBe(false); + if (!decision.allowed) { + expect(decision.error.message).toMatch(/No authorized business scope/); + } + }); +}); + +describe('evaluateToolPolicy — roles', () => { + const roleScopedPolicy: ToolAuthPolicy = { + requiredRoles: ['read:reports', 'admin'], + requiresBusinessScope: false, + dataClassification: 'business', + }; + + it('allows when the caller holds one of the required roles (any-of)', () => { + const decision = evaluateToolPolicy({ + policy: roleScopedPolicy, + auth: authContext([M('b1')], ['read:reports']), + }); + expect(decision.allowed).toBe(true); + }); + + it('denies when the caller holds none of the required roles', () => { + const decision = evaluateToolPolicy({ + policy: roleScopedPolicy, + auth: authContext([M('b1')], ['read:charges']), + }); + expect(decision.allowed).toBe(false); + if (!decision.allowed) { + expect(decision.error.message).toMatch(/missing a required role/); + } + }); + + it('applies no role gate when requiredRoles is omitted', () => { + const decision = evaluateToolPolicy({ + policy: { requiresBusinessScope: false, dataClassification: 'public' }, + auth: authContext([], []), + }); + expect(decision).toEqual({ allowed: true, readScope: { businessIds: [] } }); + }); +}); + +describe('authorizeToolCall', () => { + it('evaluates the policy of a registered tool', () => { + const tool = { + name: 't', + description: 'd', + inputSchema: undefined as never, + policy: scopedPolicy, + handler: () => ({ content: [] }), + } as unknown as ToolDefinition; + + const decision = authorizeToolCall(tool, authContext([M('b1')]), ['b1']); + expect(decision).toEqual({ allowed: true, readScope: { businessIds: ['b1'] } }); + }); +}); diff --git a/packages/mcp-server/src/tools/policy.ts b/packages/mcp-server/src/tools/policy.ts new file mode 100644 index 000000000..bf3f28301 --- /dev/null +++ b/packages/mcp-server/src/tools/policy.ts @@ -0,0 +1,82 @@ +import { + resolveRequestedReadScope, + type AuthorizedReadScope, + type McpAuthContext, +} from '../auth/identity.js'; +import type { ToolAuthPolicy, ToolDefinition } from './registry.js'; + +/** + * Per-tool authorization policy evaluator (spec §7). + * + * Runs BEFORE a tool handler executes. It enforces the tool's required roles + * and business-scope constraints against the caller's auth context, and applies + * optional caller-provided scope narrowing — but only ever as a subset of the + * caller's authorized memberships. Any request outside those memberships is + * denied with a deterministic AUTHORIZATION_ERROR (never silently narrowed). + */ + +/** Deterministic authorization error payload (spec §10.2). */ +export interface AuthorizationError { + code: 'AUTHORIZATION_ERROR'; + message: string; +} + +export type PolicyDecision = + { allowed: true; readScope: AuthorizedReadScope } | { allowed: false; error: AuthorizationError }; + +function deny(message: string): PolicyDecision { + return { allowed: false, error: { code: 'AUTHORIZATION_ERROR', message } }; +} + +export interface EvaluatePolicyParams { + policy: ToolAuthPolicy; + auth: McpAuthContext; + /** Optional caller-provided business-scope narrowing (subset of memberships). */ + requestedBusinessIds?: readonly string[]; +} + +/** + * Evaluate a tool's policy for a caller. Returns the resolved read scope when + * allowed, or an AUTHORIZATION_ERROR when denied. + * + * Rules: + * - Required roles (any-of): the caller must hold at least one when the policy + * lists any. No list ⇒ no role gate. + * - Requested scope narrowing must be a subset of the caller's memberships; + * otherwise the request is denied (not silently dropped). + * - When the tool requires business scope, the resolved scope must be + * non-empty (a caller with no memberships cannot use it). + */ +export function evaluateToolPolicy(params: EvaluatePolicyParams): PolicyDecision { + const { policy, auth, requestedBusinessIds } = params; + + // 1. Role gate (any-of). + if (policy.requiredRoles && policy.requiredRoles.length > 0) { + const held = new Set(auth.roles); + if (!policy.requiredRoles.some(role => held.has(role))) { + return deny('Caller is missing a required role for this tool'); + } + } + + // 2. Resolve requested scope as a subset of authorized memberships. + const readScope = resolveRequestedReadScope(auth, requestedBusinessIds); + if (readScope === null) { + return deny('Requested business scope is outside your authorized memberships'); + } + + // 3. Business-scope requirement. + if (policy.requiresBusinessScope && readScope.businessIds.length === 0) { + return deny('No authorized business scope for this request'); + } + + return { allowed: true, readScope }; +} + +/** Convenience: evaluate the policy of a registered tool. */ +export function authorizeToolCall( + tool: ToolDefinition, + auth: McpAuthContext, + requestedBusinessIds?: readonly string[], +): PolicyDecision { + return evaluateToolPolicy({ policy: tool.policy, auth, requestedBusinessIds }); +} From cbf4f4993568ca5568f7db1787ec22f522c9111f Mon Sep 17 00:00:00 2001 From: Gil Gardosh Date: Sun, 26 Jul 2026 19:48:32 +0300 Subject: [PATCH 22/38] feat(mcp-server): hardened upstream GraphQL client (MCP Prompt 12) (#4013) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mcp-server): hardened upstream GraphQL client (Prompt 12) Add the shared read-only upstream GraphQL client used by tool handlers (docs/mcp/spec.md §5.3, §10.3, §10.4). - src/upstream/graphql-client.ts: UpstreamGraphQLClient with strict timeout + cancellation (AbortController), bounded retries for idempotent read failures only (network/timeout/5xx; never 4xx or GraphQL errors), correlation-id and Authorization header propagation (token never logged), and sanitized UpstreamError (UPSTREAM_ERROR/TIMEOUT_ERROR, no internal details) - read-only guard refuses mutations/subscriptions; createReadOperation exposes only typed read wrappers to tools (no generic execute surface) - src/upstream/default-client.ts: env-backed, memoized client factory - unit tests for success/header propagation, read-only guard, timeout, retry eligibility (503 retried, 400/401 not, exhaustion), and error sanitization Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo * fix(mcp-server): harden upstream client against malformed input Address review: - read-only guard rejects mutation/subscription anywhere in the document (not just leading), preventing a multi-operation + operationName bypass - sanitizeGraphQLErrors tolerates null/non-object entries in the errors array - wrap response.json() and guard the body shape so a non-JSON/invalid response becomes a sanitized UpstreamError instead of a raw SyntaxError/TypeError Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo * fix(mcp-server): cap upstream error strings; extend timeout over body read Address PR review feedback on Prompt 12: - sanitizeGraphQLErrors now normalizes whitespace and caps each message (200 chars) and the combined string (500 chars), so a verbose/multi-line upstream error can neither bloat logs nor smuggle internal detail into the summary. - The request timeout budget now spans the entire exchange — obtaining the response AND consuming its body — instead of being cleared right after headers arrive. An abort raised while reading the body maps to a retryable TIMEOUT_ERROR rather than being misclassified as a non-JSON response. - Clarify the UpstreamError doc: its message carries no upstream internals; the local JS stack is our own call site. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo --------- Co-authored-by: Claude --- packages/mcp-server/README.md | 17 +- .../upstream/__tests__/graphql-client.test.ts | 221 ++++++++++++++++ .../mcp-server/src/upstream/default-client.ts | 21 ++ .../mcp-server/src/upstream/graphql-client.ts | 237 ++++++++++++++++++ 4 files changed, 493 insertions(+), 3 deletions(-) create mode 100644 packages/mcp-server/src/upstream/__tests__/graphql-client.test.ts create mode 100644 packages/mcp-server/src/upstream/default-client.ts create mode 100644 packages/mcp-server/src/upstream/graphql-client.ts diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index b14d951cd..0f71b3483 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -16,9 +16,20 @@ implementation blueprint. It currently contains the package skeleton, strict env configuration, a minimal HTTP server with a `/health` endpoint and graceful shutdown, an MCP transport route (`POST /mcp`) that speaks JSON-RPC 2.0 and lists an internal smoke tool, per-request structured logging with request/correlation ids, the OAuth protected-resource metadata endpoint, -Auth0 bearer-token verification, and identity mapping from a verified token to an internal user + -business-membership context. Fine-grained authorization and production tools are not implemented -yet. +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. + +## Upstream GraphQL client + +Tool handlers talk to the Accounter GraphQL server through a single hardened client +(`src/upstream/graphql-client.ts`): a strict per-request **timeout** with cancellation, **bounded +retries** for idempotent read failures only (network errors, timeouts, and 5xx — never 4xx +auth/validation errors or GraphQL-level errors), **header propagation** of the correlation id and +the caller's `Authorization` bearer token, and **sanitized** upstream errors (no stack traces or +internal details). Phase 1 is read-only: mutations/subscriptions are refused, and there is **no** +generic "execute anything" surface — tools use typed read-only wrappers via `createReadOperation`. ## Identity & tenant scope diff --git a/packages/mcp-server/src/upstream/__tests__/graphql-client.test.ts b/packages/mcp-server/src/upstream/__tests__/graphql-client.test.ts new file mode 100644 index 000000000..62a8867ee --- /dev/null +++ b/packages/mcp-server/src/upstream/__tests__/graphql-client.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + createReadOperation, + UpstreamError, + UpstreamGraphQLClient, +} from '../graphql-client.js'; + +const ENDPOINT = 'http://localhost:4000/graphql'; + +function jsonResponse(body: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + } as unknown as Response; +} + +function client(fetchImpl: typeof fetch, maxRetries = 2) { + return new UpstreamGraphQLClient({ endpoint: ENDPOINT, timeoutMs: 1000, maxRetries, fetchImpl }); +} + +const ctx = { correlationId: 'corr-1', authorization: 'Bearer tok' }; + +describe('UpstreamGraphQLClient.query — success & headers', () => { + it('returns data and propagates correlation id + Authorization', async () => { + const fetchImpl = vi.fn(async () => jsonResponse({ data: { charges: [{ id: '1' }] } })); + const result = await client(fetchImpl as unknown as typeof fetch).query<{ + charges: Array<{ id: string }>; + }>({ query: 'query Q { charges { id } }' }, ctx); + + expect(result).toEqual({ charges: [{ id: '1' }] }); + const [url, init] = fetchImpl.mock.calls[0]; + expect(url).toBe(ENDPOINT); + const headers = (init as RequestInit).headers as Record; + expect(headers['X-Correlation-Id']).toBe('corr-1'); + expect(headers.Authorization).toBe('Bearer tok'); + }); + + it('omits Authorization when the context has no token', async () => { + const fetchImpl = vi.fn(async () => jsonResponse({ data: { ok: true } })); + await client(fetchImpl as unknown as typeof fetch).query( + { query: 'query { ok }' }, + { correlationId: 'c' }, + ); + const headers = (fetchImpl.mock.calls[0][1] as RequestInit).headers as Record; + expect('Authorization' in headers).toBe(false); + }); +}); + +describe('UpstreamGraphQLClient.query — read-only guard', () => { + it('refuses mutations without calling fetch', async () => { + const fetchImpl = vi.fn(); + await expect( + client(fetchImpl as unknown as typeof fetch).query( + { query: ' mutation M { deleteCharge }' }, + ctx, + ), + ).rejects.toMatchObject({ code: 'UPSTREAM_ERROR', retryable: false }); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('refuses subscriptions', async () => { + const fetchImpl = vi.fn(); + await expect( + client(fetchImpl as unknown as typeof fetch).query({ query: 'subscription S { x }' }, ctx), + ).rejects.toBeInstanceOf(UpstreamError); + }); + + it('refuses a mutation smuggled after a leading query (multi-operation)', async () => { + const fetchImpl = vi.fn(); + await expect( + client(fetchImpl as unknown as typeof fetch).query( + { query: 'query Q { x } mutation M { deleteCharge }', operationName: 'M' }, + ctx, + ), + ).rejects.toBeInstanceOf(UpstreamError); + expect(fetchImpl).not.toHaveBeenCalled(); + }); +}); + +describe('UpstreamGraphQLClient.query — timeout & retries', () => { + it('maps an aborted request to a retryable TIMEOUT_ERROR', async () => { + const abortErr = Object.assign(new Error('aborted'), { name: 'AbortError' }); + const fetchImpl = vi.fn(async () => { + throw abortErr; + }); + await expect( + client(fetchImpl as unknown as typeof fetch, 0).query({ query: 'query { x }' }, ctx), + ).rejects.toMatchObject({ code: 'TIMEOUT_ERROR', retryable: true }); + }); + + it('maps an abort while reading the body to a retryable TIMEOUT_ERROR', async () => { + const abortErr = Object.assign(new Error('aborted'), { name: 'AbortError' }); + const fetchImpl = vi.fn( + async () => + ({ + ok: true, + status: 200, + json: async () => { + throw abortErr; + }, + }) as unknown as Response, + ); + await expect( + client(fetchImpl as unknown as typeof fetch, 0).query({ query: 'query { x }' }, ctx), + ).rejects.toMatchObject({ code: 'TIMEOUT_ERROR', retryable: true }); + }); + + it('retries a 503 up to maxRetries then succeeds', async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(jsonResponse({}, 503)) + .mockResolvedValueOnce(jsonResponse({}, 503)) + .mockResolvedValueOnce(jsonResponse({ data: { ok: 1 } })); + const result = await client(fetchImpl as unknown as typeof fetch, 2).query( + { query: 'query { ok }' }, + ctx, + ); + expect(result).toEqual({ ok: 1 }); + expect(fetchImpl).toHaveBeenCalledTimes(3); + }); + + it('does not retry a 400 (validation/client error)', async () => { + const fetchImpl = vi.fn(async () => jsonResponse({}, 400)); + await expect( + client(fetchImpl as unknown as typeof fetch).query({ query: 'query { x }' }, ctx), + ).rejects.toMatchObject({ code: 'UPSTREAM_ERROR', retryable: false }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it('does not retry a 401 (auth error)', async () => { + const fetchImpl = vi.fn(async () => jsonResponse({}, 401)); + await expect( + client(fetchImpl as unknown as typeof fetch).query({ query: 'query { x }' }, ctx), + ).rejects.toMatchObject({ retryable: false }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it('gives up after exhausting retries on persistent 5xx', async () => { + const fetchImpl = vi.fn(async () => jsonResponse({}, 500)); + await expect( + client(fetchImpl as unknown as typeof fetch, 2).query({ query: 'query { x }' }, ctx), + ).rejects.toBeInstanceOf(UpstreamError); + expect(fetchImpl).toHaveBeenCalledTimes(3); + }); +}); + +describe('UpstreamGraphQLClient.query — GraphQL errors', () => { + it('sanitizes GraphQL errors and does not retry them', async () => { + const fetchImpl = vi.fn(async () => + jsonResponse({ errors: [{ message: 'Charge not found' }, { message: 'Access denied' }] }), + ); + await expect( + client(fetchImpl as unknown as typeof fetch).query({ query: 'query { x }' }, ctx), + ).rejects.toMatchObject({ code: 'UPSTREAM_ERROR', retryable: false }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it('throws when the response has no data', async () => { + const fetchImpl = vi.fn(async () => jsonResponse({})); + await expect( + client(fetchImpl as unknown as typeof fetch).query({ query: 'query { x }' }, ctx), + ).rejects.toMatchObject({ message: 'Upstream returned no data' }); + }); + + it('sanitizes a non-JSON response instead of leaking the parse error', async () => { + const fetchImpl = vi.fn( + async () => + ({ + ok: true, + status: 200, + json: async () => { + throw new SyntaxError('Unexpected token < in JSON'); + }, + }) as unknown as Response, + ); + await expect( + client(fetchImpl as unknown as typeof fetch).query({ query: 'query { x }' }, ctx), + ).rejects.toMatchObject({ code: 'UPSTREAM_ERROR', message: 'Upstream returned a non-JSON response' }); + }); + + it('tolerates null entries in the errors array', async () => { + const fetchImpl = vi.fn(async () => + jsonResponse({ errors: [null, { message: 'Access denied' }] }), + ); + await expect( + client(fetchImpl as unknown as typeof fetch).query({ query: 'query { x }' }, ctx), + ).rejects.toMatchObject({ code: 'UPSTREAM_ERROR', retryable: false }); + }); + + it('normalizes whitespace and caps long GraphQL error messages', async () => { + const noisy = `Internal error:\n ${'x'.repeat(500)}\n\tinternal stack line`; + const fetchImpl = vi.fn(async () => jsonResponse({ errors: [{ message: noisy }] })); + let caught: unknown; + try { + await client(fetchImpl as unknown as typeof fetch).query({ query: 'query { x }' }, ctx); + } catch (error) { + caught = error; + } + const message = (caught as Error).message; + expect(message).not.toMatch(/[\n\t]/); + // Single message is capped at 200 chars, so the trailing "stack line" is dropped. + expect(message.length).toBeLessThanOrEqual(200); + expect(message.startsWith('Internal error: xxxx')).toBe(true); + expect(message).not.toContain('internal stack line'); + }); +}); + +describe('createReadOperation', () => { + it('runs a typed read operation and maps the data', async () => { + const fetchImpl = vi.fn(async () => jsonResponse({ data: { tags: [{ name: 'food' }] } })); + const listTags = createReadOperation< + { tags: Array<{ name: string }> }, + Record, + string[] + >('query Tags { tags { name } }', data => data.tags.map(t => t.name)); + + const names = await listTags(client(fetchImpl as unknown as typeof fetch), {}, ctx); + expect(names).toEqual(['food']); + }); +}); diff --git a/packages/mcp-server/src/upstream/default-client.ts b/packages/mcp-server/src/upstream/default-client.ts new file mode 100644 index 000000000..f53d92ece --- /dev/null +++ b/packages/mcp-server/src/upstream/default-client.ts @@ -0,0 +1,21 @@ +import { env } from '../config/env.js'; +import { UpstreamGraphQLClient } from './graphql-client.js'; + +let cached: UpstreamGraphQLClient | undefined; + +/** + * The process-wide upstream GraphQL client, configured from the environment + * (endpoint + timeout budget). Lazily created and memoized. + */ +export function getUpstreamClient(): UpstreamGraphQLClient { + cached ??= new UpstreamGraphQLClient({ + endpoint: env.upstream.graphqlUrl, + timeoutMs: env.upstream.timeoutMs, + }); + return cached; +} + +/** Test-only: reset the memoized client. */ +export function resetUpstreamClient(): void { + cached = undefined; +} diff --git a/packages/mcp-server/src/upstream/graphql-client.ts b/packages/mcp-server/src/upstream/graphql-client.ts new file mode 100644 index 000000000..66b6ec644 --- /dev/null +++ b/packages/mcp-server/src/upstream/graphql-client.ts @@ -0,0 +1,237 @@ +/** + * Shared upstream GraphQL client used by tool handlers. + * + * Design constraints (spec §5.3, §8.3, §10): + * - Phase 1 is read-only: mutations/subscriptions are refused. + * - No generic "execute anything" is exposed to tools — tools call typed + * read-only operation wrappers built on `createReadOperation`, never this + * engine directly. + * - A strict timeout budget with cancellation; bounded retries for idempotent + * read failures only (never on auth/validation errors). + * - The correlation id and the caller's Authorization header are propagated + * upstream; the raw token is never logged or persisted. + * - Upstream errors are sanitized (no stack traces / internal SQL details). + */ + +export type UpstreamErrorCode = 'UPSTREAM_ERROR' | 'TIMEOUT_ERROR'; + +/** + * Sanitized upstream failure. Its `message` is business-safe: it never carries + * upstream stack traces, SQL, or other internal upstream details. (Like any JS + * Error it still has a local `.stack` for this call site — that's ours, not the + * upstream's internals.) + */ +export class UpstreamError extends Error { + constructor( + public readonly code: UpstreamErrorCode, + message: string, + public readonly retryable: boolean, + ) { + super(message); + this.name = 'UpstreamError'; + } +} + +export interface GraphQLRequest> { + query: string; + variables?: TVariables; + operationName?: string; +} + +/** Per-call context propagated to the upstream server. */ +export interface UpstreamRequestContext { + correlationId: string; + /** + * Authorization header value (`Bearer `) forwarded from the + * authenticated request. Never logged. Omitted ⇒ no header sent. + */ + authorization?: string; +} + +export interface UpstreamClientConfig { + endpoint: string; + timeoutMs: number; + /** Max retry attempts for idempotent read failures (default 2). */ + maxRetries?: number; + /** Injectable fetch (defaults to global fetch) for testing. */ + fetchImpl?: typeof fetch; +} + +interface GraphQLResponseBody { + data?: TData; + errors?: Array<{ message?: unknown }>; +} + +// Reject a `mutation`/`subscription` keyword anywhere in the document, not just +// at the start — a multi-operation document could otherwise smuggle a mutation +// past a leading `query` and select it via `operationName`. Our own wrappers only +// send read queries, so over-rejecting here is a safe default. +const NON_READ_OPERATION_RE = /\b(mutation|subscription)\b/i; + +function assertReadOnly(query: string): void { + if (NON_READ_OPERATION_RE.test(query)) { + throw new UpstreamError('UPSTREAM_ERROR', 'Only read-only operations are permitted', false); + } +} + +/** Max characters kept per individual upstream error message. */ +const MAX_ERROR_MESSAGE_LENGTH = 200; +/** Max characters for the combined sanitized error string. */ +const MAX_SANITIZED_ERROR_LENGTH = 500; + +/** Collapse GraphQL error messages into a single business-safe string. */ +function sanitizeGraphQLErrors(errors: Array<{ message?: unknown }>): string { + const messages = errors + .map(error => + error && typeof error === 'object' && typeof error.message === 'string' ? error.message : '', + ) + // Normalize whitespace (collapse newlines/tabs/runs of spaces) and cap each + // message so a verbose upstream error can neither bloat logs nor smuggle + // multi-line internal detail into the business-safe summary. + .map(message => message.replace(/\s+/g, ' ').trim().slice(0, MAX_ERROR_MESSAGE_LENGTH)) + .filter(Boolean) + .slice(0, 3); + if (messages.length === 0) { + return 'Upstream GraphQL error'; + } + return messages.join('; ').slice(0, MAX_SANITIZED_ERROR_LENGTH); +} + +export class UpstreamGraphQLClient { + private readonly endpoint: string; + private readonly timeoutMs: number; + private readonly maxRetries: number; + private readonly fetchImpl: typeof fetch; + + constructor(config: UpstreamClientConfig) { + this.endpoint = config.endpoint; + this.timeoutMs = config.timeoutMs; + this.maxRetries = config.maxRetries ?? 2; + this.fetchImpl = config.fetchImpl ?? globalThis.fetch; + } + + /** + * Execute a read-only GraphQL operation with timeout, bounded retries, header + * propagation, and error sanitization. Internal engine — tools use typed + * wrappers ({@link createReadOperation}), not this method directly. + */ + async query(request: GraphQLRequest, context: UpstreamRequestContext): Promise { + assertReadOnly(request.query); + + let attempt = 0; + // Total tries = 1 + maxRetries; only retryable failures loop. + for (;;) { + try { + return await this.executeOnce(request, context); + } catch (error) { + const isRetryable = error instanceof UpstreamError && error.retryable; + if (!isRetryable || attempt >= this.maxRetries) { + throw error; + } + attempt += 1; + } + } + } + + private async executeOnce( + request: GraphQLRequest, + context: UpstreamRequestContext, + ): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + + const headers: Record = { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'X-Correlation-Id': context.correlationId, + }; + // Forward the caller's bearer token so upstream applies the same identity. + if (context.authorization) { + headers.Authorization = context.authorization; + } + + // The timeout budget spans the entire exchange — obtaining the response AND + // consuming its body — so an upstream that sends headers then stalls the body + // is still aborted. The timer is cleared only once everything is done. + try { + let response: Response; + try { + response = await this.fetchImpl(this.endpoint, { + method: 'POST', + headers, + body: JSON.stringify({ + query: request.query, + variables: request.variables ?? {}, + ...(request.operationName ? { operationName: request.operationName } : {}), + }), + signal: controller.signal, + }); + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw new UpstreamError('TIMEOUT_ERROR', 'Upstream request timed out', true); + } + // Network/connection error — a read may be safely retried. + throw new UpstreamError('UPSTREAM_ERROR', 'Upstream request failed', true); + } + + if (!response.ok) { + // 5xx is transient (retryable); 4xx (auth/validation) is not. + const retryable = response.status >= 500; + throw new UpstreamError( + 'UPSTREAM_ERROR', + `Upstream responded with status ${response.status}`, + retryable, + ); + } + + let body: GraphQLResponseBody; + try { + body = (await response.json()) as GraphQLResponseBody; + } catch (error) { + // An abort raised while streaming the body is a timeout, not a malformed + // body — classify it as a retryable TIMEOUT_ERROR. + if (error instanceof Error && error.name === 'AbortError') { + throw new UpstreamError('TIMEOUT_ERROR', 'Upstream request timed out', true); + } + // Non-JSON body (e.g. an HTML error page) — sanitize rather than leak. + throw new UpstreamError('UPSTREAM_ERROR', 'Upstream returned a non-JSON response', false); + } + if (!body || typeof body !== 'object') { + throw new UpstreamError( + 'UPSTREAM_ERROR', + 'Upstream returned an invalid response body', + false, + ); + } + if (Array.isArray(body.errors) && body.errors.length > 0) { + // GraphQL-level errors are not retried (not transient). + throw new UpstreamError('UPSTREAM_ERROR', sanitizeGraphQLErrors(body.errors), false); + } + if (body.data === undefined) { + throw new UpstreamError('UPSTREAM_ERROR', 'Upstream returned no data', false); + } + return body.data; + } finally { + clearTimeout(timer); + } + } +} + +/** + * Build a typed, read-only operation wrapper. This is the ONLY surface tool + * handlers use to talk to the upstream — there is no generic execute exposed to + * tools. `map` shapes the raw GraphQL data into the tool's return type. + */ +export function createReadOperation, TResult>( + query: string, + map: (data: TData) => TResult, +): ( + client: UpstreamGraphQLClient, + variables: TVariables, + context: UpstreamRequestContext, +) => Promise { + return async (client, variables, context) => { + const data = await client.query({ query, variables }, context); + return map(data); + }; +} From 4bfb7d334421539a0d623d8ab0505b022744efcc Mon Sep 17 00:00:00 2001 From: Gil Gardosh Date: Sun, 26 Jul 2026 20:01:11 +0300 Subject: [PATCH 23/38] feat(mcp-server): resolve memberships from the server (MCP Prompt 08b) (#4051) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mcp-server): resolve memberships from the server (MCP Prompt 08b) Resolve the caller's business memberships by forwarding their bearer token to the Accounter server's `myMemberships` query, and make that the wired membership source — the connector no longer derives memberships from token claims (spec §6.4, §7.1). - Add an upstream `MembershipSource` (`createUpstreamMembershipSource`) that issues `myMemberships` via the existing `createReadOperation` + `UpstreamGraphQLClient`, threading the caller's Authorization header and correlation id, and maps rows through the shared `coerceMembership`. - Wire it into `resolveAuthContext` in the MCP handler, replacing the default claims source. The `MembershipSource` seam stays pluggable and `membershipsFromClaims` remains exported for tests. - Error semantics: any upstream/auth/infra failure throws (surfaces as 401/5xx) so a failed lookup never degrades into a silent empty scope; a genuinely empty server result maps to []. A non-list payload is treated as an upstream contract violation. The token is never logged. Precheck note: the forwarded token authenticates to the server only if the server accepts the MCP's Auth0 audience. If the audiences differ, a token-acquisition step (shared audience or Auth0 token-exchange) is needed before this is enabled in a live environment. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo * fix(mcp-server): drop unused businessName; guard non-object upstream data (08b) Address PR review feedback on Prompt 08b: - The myMemberships query no longer selects businessName: the internal BusinessMembership shape keeps only { businessId, roleId }, so the field was fetched and immediately dropped. - Guard against a non-object `data` (e.g. a misbehaving upstream returning `data: null`, which the client passes through since only `undefined` is "no data") before reading `data.myMemberships`, raising a sanitized UpstreamError instead of a raw TypeError. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo --------- Co-authored-by: Claude --- packages/mcp-server/src/auth/identity.ts | 9 +- .../src/mcp/__tests__/handler.test.ts | 10 ++ packages/mcp-server/src/mcp/handler.ts | 25 ++-- .../upstream/__tests__/memberships.test.ts | 119 ++++++++++++++++++ .../mcp-server/src/upstream/memberships.ts | 114 +++++++++++++++++ 5 files changed, 268 insertions(+), 9 deletions(-) create mode 100644 packages/mcp-server/src/upstream/__tests__/memberships.test.ts create mode 100644 packages/mcp-server/src/upstream/memberships.ts diff --git a/packages/mcp-server/src/auth/identity.ts b/packages/mcp-server/src/auth/identity.ts index bb53184e7..de5b5e1a4 100644 --- a/packages/mcp-server/src/auth/identity.ts +++ b/packages/mcp-server/src/auth/identity.ts @@ -114,7 +114,14 @@ export type MembershipSource = ( principal: AuthPrincipal, ) => readonly BusinessMembership[] | Promise; -function coerceMembership(entry: unknown): BusinessMembership | null { +/** + * Coerce a single raw membership entry (a token claim entry or a + * `myMemberships` GraphQL row) into the internal {@link BusinessMembership} + * shape, or `null` when it is not a usable membership. Exported so the upstream + * membership source (which resolves memberships from the server) maps rows the + * same way the claims source does. + */ +export function coerceMembership(entry: unknown): BusinessMembership | null { // Arrays are objects too; exclude them and any non-object claim entries. if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { return null; diff --git a/packages/mcp-server/src/mcp/__tests__/handler.test.ts b/packages/mcp-server/src/mcp/__tests__/handler.test.ts index 5a9dd4e26..8bf5bb07c 100644 --- a/packages/mcp-server/src/mcp/__tests__/handler.test.ts +++ b/packages/mcp-server/src/mcp/__tests__/handler.test.ts @@ -13,6 +13,16 @@ import { SMOKE_TOOL_NAME } from '../tools.js'; vi.mock('../../auth/verifier.js', () => ({ verifyAccessToken: vi.fn() })); const mockVerify = vi.mocked(verifyAccessToken); +// Authentication now resolves memberships from the upstream server. Stub the +// source so the authenticated path resolves to an empty scope without touching +// the network (the source itself is covered in upstream/__tests__/memberships). +vi.mock('../../upstream/memberships.js', () => ({ + createUpstreamMembershipSource: () => () => Promise.resolve([]), +})); +// The upstream client is built from env; stub its accessor so the authenticated +// path doesn't force upstream env to be configured for these transport tests. +vi.mock('../../upstream/default-client.js', () => ({ getUpstreamClient: () => ({}) })); + const PRINCIPAL = { subject: 'user-1', issuer: 'https://tenant.auth0.com/', diff --git a/packages/mcp-server/src/mcp/handler.ts b/packages/mcp-server/src/mcp/handler.ts index 2ef7caedb..9856420a9 100644 --- a/packages/mcp-server/src/mcp/handler.ts +++ b/packages/mcp-server/src/mcp/handler.ts @@ -8,10 +8,12 @@ import { } from '../auth/token.js'; import { verifyAccessToken } from '../auth/verifier.js'; import { env } from '../config/env.js'; -import { getRequestContext } from '../context.js'; +import { generateId, getRequestContext } from '../context.js'; import { createRequestLogger, log } from '../logger.js'; import { sendUnauthorized } from '../oauth/challenge.js'; import { protectedResourceMetadataUrl } from '../oauth/metadata.js'; +import { getUpstreamClient } from '../upstream/default-client.js'; +import { createUpstreamMembershipSource } from '../upstream/memberships.js'; import { getServiceVersion, SERVICE_NAME } from '../version.js'; import { asJsonRpcRequest, @@ -28,10 +30,10 @@ import { listedTools, runSmokeTool, SMOKE_TOOL_NAME } from './tools.js'; * MCP transport route (Streamable HTTP) — request dispatch skeleton. * * Handles the JSON-RPC methods needed to establish a session and list tools. - * It is intentionally auth-agnostic for now (authentication and per-tool - * authorization are layered on in later steps) and performs NO upstream - * GraphQL calls. Unknown methods get a deterministic JSON-RPC "method not - * found" error. + * The JSON-RPC dispatcher itself performs no upstream GraphQL calls (per-tool + * authorization is layered on in later steps); the one upstream call here is in + * `authenticate`, which resolves the caller's business memberships from the + * server. Unknown methods get a deterministic JSON-RPC "method not found" error. */ /** MCP protocol revision this server implements. */ @@ -180,9 +182,16 @@ async function authenticate( const principal = await verifyAccessToken(token); setAuthPrincipal(req, principal); // Map the verified identity to internal user + business membership context. - // An empty membership set is a valid user with no access; per-tool policy - // (a later step) decides what that permits. - setAuthContext(req, await resolveAuthContext(principal)); + // Memberships are resolved from the Accounter server (not token claims) by + // forwarding the caller's bearer token to `myMemberships`. An upstream/auth + // failure throws (surfaces as 401/5xx); only a genuinely empty membership + // set resolves to no access, and per-tool policy decides what that permits. + const membershipSource = createUpstreamMembershipSource({ + client: getUpstreamClient(), + authorization: req.headers.authorization, + correlationId: getRequestContext(req)?.correlationId ?? generateId(), + }); + setAuthContext(req, await resolveAuthContext(principal, membershipSource)); return principal; } catch (error) { // An invalid token, or a verified token that cannot be mapped to a usable diff --git a/packages/mcp-server/src/upstream/__tests__/memberships.test.ts b/packages/mcp-server/src/upstream/__tests__/memberships.test.ts new file mode 100644 index 000000000..595f9a884 --- /dev/null +++ b/packages/mcp-server/src/upstream/__tests__/memberships.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it, vi } from 'vitest'; +import { UpstreamError, type UpstreamGraphQLClient } from '../graphql-client.js'; +import { createUpstreamMembershipSource, MY_MEMBERSHIPS_QUERY } from '../memberships.js'; + +const PRINCIPAL = { + subject: 'user-1', + issuer: 'https://tenant.auth0.com/', + audience: 'aud', + scopes: [], + email: null, + expiresAt: undefined, + claims: { sub: 'user-1' }, +}; + +function fakeClient(query: ReturnType): UpstreamGraphQLClient { + return { query } as unknown as UpstreamGraphQLClient; +} + +describe('createUpstreamMembershipSource', () => { + it('maps myMemberships rows to the internal membership shape', async () => { + const query = vi.fn().mockResolvedValue({ + myMemberships: [ + { businessId: 'b1', roleId: 'business_owner', businessName: 'Acme' }, + { businessId: 'b2', roleId: 'accountant', businessName: null }, + ], + }); + const source = createUpstreamMembershipSource({ + client: fakeClient(query), + authorization: 'Bearer forwarded-token', + correlationId: 'corr-1', + }); + + const memberships = await source(PRINCIPAL); + + // businessName is intentionally dropped: the internal shape is {businessId, roleId}. + expect(memberships).toEqual([ + { businessId: 'b1', roleId: 'business_owner' }, + { businessId: 'b2', roleId: 'accountant' }, + ]); + }); + + it('forwards the authorization header and correlation id, and issues myMemberships', async () => { + const query = vi.fn().mockResolvedValue({ myMemberships: [] }); + const source = createUpstreamMembershipSource({ + client: fakeClient(query), + authorization: 'Bearer forwarded-token', + correlationId: 'corr-1', + }); + + await source(PRINCIPAL); + + expect(query).toHaveBeenCalledTimes(1); + const [request, context] = query.mock.calls[0]; + expect(request.query).toBe(MY_MEMBERSHIPS_QUERY); + expect(context).toEqual({ correlationId: 'corr-1', authorization: 'Bearer forwarded-token' }); + }); + + it('maps an empty server result to an empty list', async () => { + const query = vi.fn().mockResolvedValue({ myMemberships: [] }); + const source = createUpstreamMembershipSource({ + client: fakeClient(query), + correlationId: 'corr-1', + }); + + await expect(source(PRINCIPAL)).resolves.toEqual([]); + }); + + it('drops malformed rows but keeps valid ones', async () => { + const query = vi.fn().mockResolvedValue({ + myMemberships: [ + { businessId: 'b1', roleId: 'business_owner' }, + { roleId: 'no-business-id' }, + null, + 'not-an-object', + ], + }); + const source = createUpstreamMembershipSource({ + client: fakeClient(query), + correlationId: 'corr-1', + }); + + await expect(source(PRINCIPAL)).resolves.toEqual([ + { businessId: 'b1', roleId: 'business_owner' }, + ]); + }); + + it('throws when the server returns null data (misbehaving upstream)', async () => { + const query = vi.fn().mockResolvedValue(null); + const source = createUpstreamMembershipSource({ + client: fakeClient(query), + correlationId: 'corr-1', + }); + + await expect(source(PRINCIPAL)).rejects.toBeInstanceOf(UpstreamError); + }); + + it('throws when the server returns a non-list payload (contract violation)', async () => { + const query = vi.fn().mockResolvedValue({ myMemberships: null }); + const source = createUpstreamMembershipSource({ + client: fakeClient(query), + correlationId: 'corr-1', + }); + + await expect(source(PRINCIPAL)).rejects.toBeInstanceOf(UpstreamError); + }); + + it('propagates upstream/auth/infra failures instead of silently emptying scope', async () => { + const query = vi + .fn() + .mockRejectedValue(new UpstreamError('UPSTREAM_ERROR', 'Upstream responded with status 401', false)); + const source = createUpstreamMembershipSource({ + client: fakeClient(query), + authorization: 'Bearer forwarded-token', + correlationId: 'corr-1', + }); + + await expect(source(PRINCIPAL)).rejects.toBeInstanceOf(UpstreamError); + }); +}); diff --git a/packages/mcp-server/src/upstream/memberships.ts b/packages/mcp-server/src/upstream/memberships.ts new file mode 100644 index 000000000..ed1c96371 --- /dev/null +++ b/packages/mcp-server/src/upstream/memberships.ts @@ -0,0 +1,114 @@ +/** + * Upstream membership source. + * + * The connector never derives business memberships from token claims (spec + * §6.4, §7.1). Instead it forwards the caller's bearer token to the Accounter + * GraphQL server and reads the memberships back from the dedicated + * `myMemberships` query — the server maps the Auth0 `sub`/verified email to + * `business_users` at request time. Because the connector holds a real + * end-user token, "authenticating to the server" is plain token pass-through: + * no Auth0 custom claim and no shared service secret. + * + * This is a {@link MembershipSource} implementation wired into + * {@link resolveAuthContext}, replacing the default claims source. + */ + +import { + coerceMembership, + type BusinessMembership, + type MembershipSource, +} from '../auth/identity.js'; +import { + createReadOperation, + UpstreamError, + type UpstreamGraphQLClient, +} from './graphql-client.js'; + +/** + * Read-only query for the authenticated caller's own business memberships. + * + * Only `businessId` and `roleId` are selected: the internal + * {@link BusinessMembership} shape keeps nothing else, so fetching `businessName` + * would just add payload and upstream work for a field that is immediately + * dropped. + */ +export const MY_MEMBERSHIPS_QUERY = /* GraphQL */ ` + query McpMyMemberships { + myMemberships { + businessId + roleId + } + } +`; + +interface MyMembershipsData { + myMemberships?: unknown; +} + +/** + * Typed, read-only wrapper around `myMemberships`. Maps the GraphQL rows to the + * internal {@link BusinessMembership} shape via the shared `coerceMembership`. + * + * A non-array payload violates the server's non-null `[MyBusinessMembership!]!` + * contract, so it is treated as an upstream error rather than silently coerced + * to an empty (unscoped) result. A legitimately empty list maps to `[]`. + */ +const runMyMemberships = createReadOperation< + MyMembershipsData, + Record, + BusinessMembership[] +>(MY_MEMBERSHIPS_QUERY, data => { + // A misbehaving upstream/proxy can return `data: null` (which the client + // passes through, since only `undefined` is treated as "no data"). Guard it + // so we raise a sanitized UpstreamError instead of a raw TypeError. + if (!data || typeof data !== 'object') { + throw new UpstreamError('UPSTREAM_ERROR', 'Upstream returned an invalid response body', false); + } + const rows = data.myMemberships; + if (!Array.isArray(rows)) { + throw new UpstreamError( + 'UPSTREAM_ERROR', + 'Upstream myMemberships did not return a list', + false, + ); + } + const memberships: BusinessMembership[] = []; + for (const row of rows) { + const membership = coerceMembership(row); + if (membership) { + memberships.push(membership); + } + } + return memberships; +}); + +export interface UpstreamMembershipSourceOptions { + /** The shared upstream GraphQL client. */ + client: UpstreamGraphQLClient; + /** + * The caller's `Authorization` header value (`Bearer `) to forward. + * Never logged. Omitted ⇒ no header sent (the upstream will reject as 401, + * which surfaces as an error rather than a silent empty scope). + */ + authorization?: string; + /** Correlation id propagated to the upstream call for tracing. */ + correlationId: string; +} + +/** + * Build a {@link MembershipSource} that resolves memberships from the server by + * forwarding the caller's token to `myMemberships`. + * + * Error semantics: any upstream/auth/infra failure throws (the caller lets it + * surface as 401/5xx) so a failed lookup never degrades into a silent empty + * scope. Only a genuinely empty server result resolves to `[]`. + * + * The identity is carried entirely by the forwarded token, so the resolved + * principal is not consulted here. + */ +export function createUpstreamMembershipSource( + options: UpstreamMembershipSourceOptions, +): MembershipSource { + const { client, authorization, correlationId } = options; + return () => runMyMemberships(client, {}, { correlationId, authorization }); +} From 11717e048a7efb5a4b0225a1705475c73693d513 Mon Sep 17 00:00:00 2001 From: Gil Gardosh Date: Wed, 29 Jul 2026 15:52:30 +0300 Subject: [PATCH 24/38] feat(mcp-server): charges search tool + registry wiring (MCP Prompt 13) (#4014) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mcp-server): charges search tool + registry wiring (Prompt 13) Add the first production tool and wire the curated registry into POST /mcp (docs/mcp/spec.md §8). - src/tools/charges.ts: accounter_search_charges — read-only allCharges query scoped to the caller's authorized businesses; strict input (date range bounded to 366 days, pageSize <= 50, tags/freeText/flow filters); normalized output + pagination metadata - src/tools/execute.ts: executeRegisteredTool runs validation -> policy -> handler and maps failures to tool-result errors (VALIDATION/AUTHORIZATION/ UPSTREAM/TIMEOUT/INTERNAL) with correlation id + retryability - src/tools/registry-instance.ts: process-wide registry with the charges tool - handler: async dispatch (dispatchMcpBody/dispatchMcpRequest) so tools/list includes registry tools and tools/call executes them; upstream client built lazily; ToolExecutionContext carries client + authorization - integration tests: success, empty, invalid filters/date/pageSize, scope narrowing + enforcement, upstream failure; dispatch lists the tool Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo * fix(mcp-server): validate single dates and log unexpected tool errors Address review: - charges assertDateRange validates each supplied date even when only one is present, rejecting impossible-but-format-matching values; day-diff via Math.round - executeRegisteredTool logs unexpected (non-Upstream) errors with the tool name, correlation id, message, and stack before returning INTERNAL_ERROR, closing an observability gap; the caller still sees only a generic message Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo * fix(mcp-server): reject impossible calendar dates; fix 1-based→0-based paging Address PR review feedback on Prompt 13: - Date validation now rejects impossible calendar dates (e.g. 2026-02-31). `Date.parse` silently rolls those over to a valid later date, so the charges tool parses YYYY-MM-DD explicitly and verifies the components round-trip. - The tool exposes a 1-based `page`, but upstream `allCharges` is 0-based (it slices `[page*limit, (page+1)*limit]`), so `page=1` skipped the first page. Translate to `page - 1` on the request; the response summary stays 1-based. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo --------- Co-authored-by: Claude --- packages/mcp-server/README.md | 17 +- .../src/mcp/__tests__/handler.test.ts | 36 ++- packages/mcp-server/src/mcp/handler.ts | 117 +++++++++- .../src/tools/__tests__/charges.test.ts | 213 +++++++++++++++++ packages/mcp-server/src/tools/charges.ts | 221 ++++++++++++++++++ packages/mcp-server/src/tools/execute.ts | 166 +++++++++++++ .../mcp-server/src/tools/registry-instance.ts | 10 + packages/mcp-server/src/tools/registry.ts | 5 + 8 files changed, 771 insertions(+), 14 deletions(-) create mode 100644 packages/mcp-server/src/tools/__tests__/charges.test.ts create mode 100644 packages/mcp-server/src/tools/charges.ts create mode 100644 packages/mcp-server/src/tools/execute.ts create mode 100644 packages/mcp-server/src/tools/registry-instance.ts 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 8bf5bb07c..a757e4217 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'; @@ -241,6 +242,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 9856420a9..384ffdf68 100644 --- a/packages/mcp-server/src/mcp/handler.ts +++ b/packages/mcp-server/src/mcp/handler.ts @@ -1,5 +1,11 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; -import { IdentityMappingError, resolveAuthContext, setAuthContext } from '../auth/identity.js'; +import { + getAuthContext, + IdentityMappingError, + resolveAuthContext, + setAuthContext, + type McpAuthContext, +} from '../auth/identity.js'; import { extractBearerToken, setAuthPrincipal, @@ -12,6 +18,8 @@ import { generateId, 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 { createUpstreamMembershipSource } from '../upstream/memberships.js'; import { getServiceVersion, SERVICE_NAME } from '../version.js'; @@ -100,32 +108,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; @@ -247,7 +329,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..9a66be75f --- /dev/null +++ b/packages/mcp-server/src/tools/__tests__/charges.test.ts @@ -0,0 +1,213 @@ +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']); + }); + + it('requests the first upstream page (0-based) for the default page', async () => { + let sentBody: unknown; + const client = clientReturning(oneCharge, body => (sentBody = body)); + await run(client, authContext(['b1']), {}); + const variables = (sentBody as { variables: { page: number } }).variables; + expect(variables.page).toBe(0); + }); + + it('translates the 1-based page to the upstream 0-based page index', async () => { + let sentBody: unknown; + const client = clientReturning(oneCharge, body => (sentBody = body)); + await run(client, authContext(['b1']), { page: 2 }); + const variables = (sentBody as { variables: { page: number } }).variables; + expect(variables.page).toBe(1); + }); +}); + +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 a day-overflow date that Date.parse would silently roll over', async () => { + const client = clientReturning(oneCharge); + // 2026-02-31 is not a real date; Date.parse rolls it to March, so it must be + // caught by explicit calendar validation, not just a NaN check. + const result = await run(client, authContext(['b1']), { toDate: '2026-02-31' }); + expect(result.isError).toBe(true); + expect((result.structuredContent as { code: string }).code).toBe('VALIDATION_ERROR'); + expect((result.structuredContent as { message: string }).message).toBe('Invalid toDate'); + }); + + 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..bbb392906 --- /dev/null +++ b/packages/mcp-server/src/tools/charges.ts @@ -0,0 +1,221 @@ +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; + +/** + * Parse a strict `YYYY-MM-DD` string to a UTC timestamp, or `null` if it is not a + * real calendar date. `Date.parse` alone is unsafe here: it silently rolls + * impossible dates over (e.g. `2026-02-31` → `2026-03-03`) instead of failing, so + * we verify the parsed components round-trip back to the input. + */ +function parseCalendarDate(value: string): number | null { + const [year, month, day] = value.split('-').map(Number); + const timestamp = Date.UTC(year, month - 1, day); + const date = new Date(timestamp); + if ( + date.getUTCFullYear() !== year || + date.getUTCMonth() !== month - 1 || + date.getUTCDate() !== day + ) { + return null; + } + return timestamp; +} + +/** 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). + let from: number | undefined; + let to: number | undefined; + if (input.fromDate !== undefined) { + const parsed = parseCalendarDate(input.fromDate); + if (parsed === null) { + throw new ToolInputError('Invalid fromDate'); + } + from = parsed; + } + if (input.toDate !== undefined) { + const parsed = parseCalendarDate(input.toDate); + if (parsed === null) { + throw new ToolInputError('Invalid toDate'); + } + to = parsed; + } + 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), + // The tool exposes a 1-based `page`, but upstream `allCharges` is 0-based + // (it slices `[page * limit, (page + 1) * limit]`), so translate here — + // otherwise page 1 would skip the first page of results. + page: input.page - 1, + 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 d9a56e070..b125bccda 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 { From 19d96b3a09e1784c64d999d09c6ddf39b40e2e18 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 29 Jul 2026 12:55:07 +0000 Subject: [PATCH 25/38] chore(dependencies): updated changesets for modified dependencies --- .changeset/@accounter_client-3941-dependencies.md | 5 +++++ .../@accounter_israeli-vat-scraper-3941-dependencies.md | 5 +++++ .../@accounter_modern-poalim-scraper-3941-dependencies.md | 5 +++++ .changeset/@accounter_scraper-app-3941-dependencies.md | 5 +++++ 4 files changed, 20 insertions(+) create mode 100644 .changeset/@accounter_client-3941-dependencies.md create mode 100644 .changeset/@accounter_israeli-vat-scraper-3941-dependencies.md create mode 100644 .changeset/@accounter_modern-poalim-scraper-3941-dependencies.md create mode 100644 .changeset/@accounter_scraper-app-3941-dependencies.md diff --git a/.changeset/@accounter_client-3941-dependencies.md b/.changeset/@accounter_client-3941-dependencies.md new file mode 100644 index 000000000..3e97b4f17 --- /dev/null +++ b/.changeset/@accounter_client-3941-dependencies.md @@ -0,0 +1,5 @@ +--- +"@accounter/client": patch +--- +dependencies updates: + - Updated dependency [`react-hook-form@7.83.0` ↗︎](https://www.npmjs.com/package/react-hook-form/v/7.83.0) (from `7.82.0`, in `dependencies`) diff --git a/.changeset/@accounter_israeli-vat-scraper-3941-dependencies.md b/.changeset/@accounter_israeli-vat-scraper-3941-dependencies.md new file mode 100644 index 000000000..d3081bb4d --- /dev/null +++ b/.changeset/@accounter_israeli-vat-scraper-3941-dependencies.md @@ -0,0 +1,5 @@ +--- +"@accounter/israeli-vat-scraper": patch +--- +dependencies updates: + - Updated dependency [`puppeteer@25.4.0` ↗︎](https://www.npmjs.com/package/puppeteer/v/25.4.0) (from `25.3.0`, in `dependencies`) diff --git a/.changeset/@accounter_modern-poalim-scraper-3941-dependencies.md b/.changeset/@accounter_modern-poalim-scraper-3941-dependencies.md new file mode 100644 index 000000000..cf44cc17d --- /dev/null +++ b/.changeset/@accounter_modern-poalim-scraper-3941-dependencies.md @@ -0,0 +1,5 @@ +--- +"@accounter/modern-poalim-scraper": patch +--- +dependencies updates: + - Updated dependency [`puppeteer@25.4.0` ↗︎](https://www.npmjs.com/package/puppeteer/v/25.4.0) (from `25.3.0`, in `dependencies`) diff --git a/.changeset/@accounter_scraper-app-3941-dependencies.md b/.changeset/@accounter_scraper-app-3941-dependencies.md new file mode 100644 index 000000000..3e19e704a --- /dev/null +++ b/.changeset/@accounter_scraper-app-3941-dependencies.md @@ -0,0 +1,5 @@ +--- +"@accounter/scraper-app": patch +--- +dependencies updates: + - Updated dependency [`@fastify/static@10.1.2` ↗︎](https://www.npmjs.com/package/@fastify/static/v/10.1.2) (from `10.1.0`, in `dependencies`) From fd6bd3c8a6b16c0fba135c5b3f1b05eeb243e7cf Mon Sep 17 00:00:00 2001 From: Gil Gardosh Date: Wed, 29 Jul 2026 23:46:04 +0300 Subject: [PATCH 26/38] feat(mcp-server): tags & tax-category lookup tools (MCP Prompt 14) (#4015) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mcp-server): tags & tax-category lookup tools (Prompt 14) Add two read-only reference lookups (docs/mcp/spec.md §8.2). - src/tools/lookups.ts: accounter_list_tags (allTags) and accounter_list_tax_categories (taxCategories) with minimal input (nameContains, limit, activeOnly), deterministic sort (name then id), and a 500-row output cap with a `truncated` flag; response fields limited to the lookup use case - both gated on business membership (scope enforcement) and registered in the process-wide registry - integration tests: sorted lookups, name filter, active filter, cap/truncation, scope enforcement, and unknown-field rejection Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo * fix(mcp-server): refine lookup sorting, mapping, messages, and tests Address review: - byNameThenId uses localeCompare (locale-aware, robust for i18n names) - tax-category handler returns the already-shaped rows directly (drop redundant identity map) - natural-language pluralization for tag/tax-category summaries - lookup tests: union type for the runTool helper; assert the full rows array Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo * fix(mcp-server): make lookup sort locale-deterministic (Prompt 14) Address PR review feedback: the name sort used `localeCompare` with an `undefined` locale, so the "deterministic order" depended on the host's default locale. Use a fixed `Intl.Collator('en')` for the name comparison and a plain codepoint compare for the id tie-break, so ordering is stable across environments. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo * feat(mcp-server): expose bookkeeping sort code in tax-category lookup Surface each tax category's sort code (the chart-of-accounts / bookkeeping grouping the IRS/6111 code defaults from) alongside irsCode. The server's TaxCategory type already exposes `sortCode: SortCode`, so the tool just selects `sortCode { key name }` and passes it through as `{ key, name } | null` (null when a category has no sort code assigned). Tests and README updated. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo --------- Co-authored-by: Claude --- packages/mcp-server/README.md | 4 + .../src/tools/__tests__/lookups.test.ts | 132 ++++++++++++ packages/mcp-server/src/tools/lookups.ts | 195 ++++++++++++++++++ .../mcp-server/src/tools/registry-instance.ts | 3 + 4 files changed, 334 insertions(+) create mode 100644 packages/mcp-server/src/tools/__tests__/lookups.test.ts create mode 100644 packages/mcp-server/src/tools/lookups.ts diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 47f92c043..488466cf5 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -33,6 +33,10 @@ payload (spec §10.2): `VALIDATION_ERROR`, `AUTHORIZATION_ERROR`, `UPSTREAM_ERRO 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. +- **`accounter_list_tags`** — list tags for categorizing charges, optionally filtered by name. + Deterministically sorted (name, then id) and size-capped (≤ 500). +- **`accounter_list_tax_categories`** — list tax categories (id, name, IRS code, bookkeeping sort + code, active flag), optionally filtered by name or active status. Same deterministic sort + cap. ## Upstream GraphQL client diff --git a/packages/mcp-server/src/tools/__tests__/lookups.test.ts b/packages/mcp-server/src/tools/__tests__/lookups.test.ts new file mode 100644 index 000000000..8b59ffcf7 --- /dev/null +++ b/packages/mcp-server/src/tools/__tests__/lookups.test.ts @@ -0,0 +1,132 @@ +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 { executeRegisteredTool } from '../execute.js'; +import { listTagsTool, listTaxCategoriesTool } from '../lookups.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) { + const fetchImpl = vi.fn( + async () => ({ 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, + }); +} + +const runTool = ( + tool: typeof listTagsTool | typeof listTaxCategoriesTool, + client: UpstreamGraphQLClient, + auth: McpAuthContext, + rawArgs: unknown, +) => executeRegisteredTool({ tool, rawArgs, auth, correlationId: 'c', client, authorization: 'Bearer t' }); + +describe('listTagsTool', () => { + const client = () => + clientReturning({ + allTags: [ + { id: '3', name: 'Zebra', namePath: ['Zebra'] }, + { id: '1', name: 'apple', namePath: ['apple'] }, + { id: '2', name: 'Banana', namePath: ['food', 'Banana'] }, + ], + }); + + it('returns tags sorted by name (case-insensitive), then id', async () => { + const result = await runTool(listTagsTool, client(), authContext(['b1']), {}); + const names = (result.structuredContent as { tags: Array<{ name: string }> }).tags.map(t => t.name); + expect(names).toEqual(['apple', 'Banana', 'Zebra']); + }); + + it('filters by nameContains (case-insensitive)', async () => { + const result = await runTool(listTagsTool, client(), authContext(['b1']), { nameContains: 'an' }); + const structured = result.structuredContent as { tags: Array<{ name: string }>; total: number }; + expect(structured.tags.map(t => t.name)).toEqual(['Banana']); + expect(structured.total).toBe(1); + }); + + it('caps results and flags truncation', async () => { + const result = await runTool(listTagsTool, client(), authContext(['b1']), { limit: 2 }); + const structured = result.structuredContent as { tags: unknown[]; total: number; truncated: boolean }; + expect(structured.tags).toHaveLength(2); + expect(structured.total).toBe(3); + expect(structured.truncated).toBe(true); + }); + + it('enforces business scope (denies a caller with no memberships)', async () => { + const result = await runTool(listTagsTool, client(), authContext([]), {}); + expect(result.isError).toBe(true); + expect((result.structuredContent as { code: string }).code).toBe('AUTHORIZATION_ERROR'); + }); + + it('rejects unknown input fields', async () => { + const result = await runTool(listTagsTool, client(), authContext(['b1']), { bogus: 1 }); + expect(result.isError).toBe(true); + expect((result.structuredContent as { code: string }).code).toBe('VALIDATION_ERROR'); + }); +}); + +describe('listTaxCategoriesTool', () => { + const client = () => + clientReturning({ + taxCategories: [ + { + id: '1', + name: 'Income', + irsCode: 100, + isActive: true, + sortCode: { key: 900, name: 'Revenue' }, + }, + { id: '2', name: 'Assets', irsCode: null, isActive: false, sortCode: null }, + ], + }); + + it('returns tax categories sorted by name with fields limited to the use case', async () => { + const result = await runTool(listTaxCategoriesTool, client(), authContext(['b1']), {}); + const rows = ( + result.structuredContent as { + taxCategories: Array<{ + name: string; + irsCode: number | null; + isActive: boolean; + sortCode: { key: number; name: string | null } | null; + }>; + } + ).taxCategories; + expect(rows).toEqual([ + { id: '2', name: 'Assets', irsCode: null, isActive: false, sortCode: null }, + { + id: '1', + name: 'Income', + irsCode: 100, + isActive: true, + sortCode: { key: 900, name: 'Revenue' }, + }, + ]); + }); + + it('filters to active categories when activeOnly is set', async () => { + const result = await runTool(listTaxCategoriesTool, client(), authContext(['b1']), { + activeOnly: true, + }); + const rows = (result.structuredContent as { taxCategories: Array<{ name: string }> }).taxCategories; + expect(rows.map(r => r.name)).toEqual(['Income']); + }); +}); diff --git a/packages/mcp-server/src/tools/lookups.ts b/packages/mcp-server/src/tools/lookups.ts new file mode 100644 index 000000000..1bc6c65e9 --- /dev/null +++ b/packages/mcp-server/src/tools/lookups.ts @@ -0,0 +1,195 @@ +import { z } from 'zod'; +import type { ToolDefinition, ToolExecutionContext, ToolResult } from './registry.js'; + +/** + * Tool 2: read-only lookups for tags and tax categories (spec §8.2). + * + * These are reference-data lookups. Input is minimal, output is deterministically + * sorted (by name, then id) and size-capped. A caller must belong to at least + * one business (scope-gated) to browse them. + */ + +export const LIST_TAGS_TOOL_NAME = 'accounter_list_tags'; +export const LIST_TAX_CATEGORIES_TOOL_NAME = 'accounter_list_tax_categories'; + +/** Hard cap on returned rows (spec §9.3). */ +export const MAX_LOOKUP_RESULTS = 500; + +const nameContains = z + .string() + .min(1) + .max(100) + .optional() + .describe('Case-insensitive substring to filter by name.'); + +const limit = z + .number() + .int() + .positive() + .max(MAX_LOOKUP_RESULTS) + .optional() + .default(MAX_LOOKUP_RESULTS) + .describe(`Maximum rows to return (capped at ${MAX_LOOKUP_RESULTS}).`); + +// Fixed-locale collator so the ordering is deterministic across hosts/runtimes +// rather than depending on the ambient default locale. +const NAME_COLLATOR = new Intl.Collator('en', { sensitivity: 'base' }); + +/** Stable order: by name (case-insensitive, fixed-locale), tie-broken by id. */ +function byNameThenId(a: { name: string; id: string }, b: { name: string; id: string }): number { + return NAME_COLLATOR.compare(a.name, b.name) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0); +} + +function applyFilterSortCap( + rows: T[], + search: string | undefined, + max: number, +): { rows: T[]; total: number; truncated: boolean } { + const needle = search?.toLowerCase(); + const filtered = needle ? rows.filter(row => row.name.toLowerCase().includes(needle)) : rows; + const sorted = [...filtered].sort(byNameThenId); + return { + rows: sorted.slice(0, max), + total: filtered.length, + truncated: filtered.length > max, + }; +} + +// --------------------------------------------------------------------------- +// Tags +// --------------------------------------------------------------------------- + +const listTagsInput = z.object({ nameContains, limit }); +type ListTagsInput = z.infer; + +const LIST_TAGS_QUERY = /* GraphQL */ ` + query McpListTags { + allTags { + id + name + namePath + } + } +`; + +interface RawTag { + id: string; + name: string; + namePath: string[] | null; +} + +async function listTagsHandler( + input: ListTagsInput, + context: ToolExecutionContext, +): Promise { + const data = await context.client.query<{ allTags: RawTag[] }>( + { query: LIST_TAGS_QUERY }, + { correlationId: context.correlationId, authorization: context.authorization }, + ); + + const { rows, total, truncated } = applyFilterSortCap( + data.allTags, + input.nameContains, + input.limit, + ); + const tags = rows.map(tag => ({ + id: tag.id, + name: tag.name, + namePath: tag.namePath ?? [tag.name], + })); + + return { + content: [ + { + type: 'text', + text: `Found ${total} ${total === 1 ? 'tag' : 'tags'}${truncated ? ' (truncated)' : ''}.`, + }, + ], + structuredContent: { tags, total, truncated }, + }; +} + +export const listTagsTool: ToolDefinition = { + name: LIST_TAGS_TOOL_NAME, + description: + 'List the tags available for categorizing charges, optionally filtered by name. Read-only.', + inputSchema: listTagsInput, + policy: { requiresBusinessScope: true, dataClassification: 'business' }, + handler: listTagsHandler, +}; + +// --------------------------------------------------------------------------- +// Tax categories +// --------------------------------------------------------------------------- + +const listTaxCategoriesInput = z.object({ + nameContains, + activeOnly: z.boolean().optional().default(false).describe('Return only active tax categories.'), + limit, +}); +type ListTaxCategoriesInput = z.infer; + +const LIST_TAX_CATEGORIES_QUERY = /* GraphQL */ ` + query McpListTaxCategories { + taxCategories { + id + name + irsCode + isActive + sortCode { + key + name + } + } + } +`; + +interface RawTaxCategory { + id: string; + name: string; + irsCode: number | null; + isActive: boolean; + /** The bookkeeping (chart-of-accounts) sort code; null when unassigned. */ + sortCode: { key: number; name: string | null } | null; +} + +async function listTaxCategoriesHandler( + input: ListTaxCategoriesInput, + context: ToolExecutionContext, +): Promise { + const data = await context.client.query<{ taxCategories: RawTaxCategory[] }>( + { query: LIST_TAX_CATEGORIES_QUERY }, + { correlationId: context.correlationId, authorization: context.authorization }, + ); + + const activeFiltered = input.activeOnly + ? data.taxCategories.filter(category => category.isActive) + : data.taxCategories; + // `rows` are already `RawTaxCategory` with exactly the fields we expose. + const { + rows: taxCategories, + total, + truncated, + } = applyFilterSortCap(activeFiltered, input.nameContains, input.limit); + + return { + content: [ + { + type: 'text', + text: `Found ${total} tax ${total === 1 ? 'category' : 'categories'}${ + truncated ? ' (truncated)' : '' + }.`, + }, + ], + structuredContent: { taxCategories, total, truncated }, + }; +} + +export const listTaxCategoriesTool: ToolDefinition = { + name: LIST_TAX_CATEGORIES_TOOL_NAME, + description: + 'List tax categories (id, name, IRS code, active flag), optionally filtered by name or active status. Read-only.', + inputSchema: listTaxCategoriesInput, + policy: { requiresBusinessScope: true, dataClassification: 'business' }, + handler: listTaxCategoriesHandler, +}; diff --git a/packages/mcp-server/src/tools/registry-instance.ts b/packages/mcp-server/src/tools/registry-instance.ts index 7bd4c0ea4..9ddf6261e 100644 --- a/packages/mcp-server/src/tools/registry-instance.ts +++ b/packages/mcp-server/src/tools/registry-instance.ts @@ -1,4 +1,5 @@ import { searchChargesTool } from './charges.js'; +import { listTagsTool, listTaxCategoriesTool } from './lookups.js'; import { ToolRegistry } from './registry.js'; /** @@ -8,3 +9,5 @@ import { ToolRegistry } from './registry.js'; export const toolRegistry = new ToolRegistry(); toolRegistry.register(searchChargesTool); +toolRegistry.register(listTagsTool); +toolRegistry.register(listTaxCategoriesTool); From 102844a968383a68ed78d09903a7e4d63e3f3f9f Mon Sep 17 00:00:00 2001 From: Gil Gardosh Date: Thu, 30 Jul 2026 09:30:16 +0300 Subject: [PATCH 27/38] feat(mcp-server): balance report tool (MCP Prompt 15) (#4016) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mcp-server): balance report tool (Prompt 15) Add one read-only report tool (docs/mcp/spec.md §8.2). - src/tools/reports.ts: accounter_balance_report — transactionsForBalanceReport for a single authorized business over a bounded date range (<= 366 days), reportType enum (BALANCE), role-gated (business_owner/accountant); output rows capped at 500 with a truncated flag to avoid large payloads - execute.ts: scope-narrowing convention also accepts a singular businessId (in addition to businessIds[]), so the report's business is validated as a subset of the caller's memberships (AUTHORIZATION_ERROR when out of scope) - registered in the process-wide registry - tests: valid report (owner scoping + normalization), invalid/oversized date range, bad format, oversized-result truncation, and role/scope denial Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo * fix(mcp-server): guard report ownerId and nullable upstream list Address review: - assert a concrete ownerId is in scope before querying (a business-scoped tool must never reach upstream without one) - default transactionsForBalanceReport to [] to tolerate a null/absent list Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo --------- Co-authored-by: Claude --- packages/mcp-server/README.md | 3 + .../src/tools/__tests__/reports.test.ts | 148 ++++++++++++++++++ packages/mcp-server/src/tools/execute.ts | 19 ++- .../mcp-server/src/tools/registry-instance.ts | 2 + packages/mcp-server/src/tools/reports.ts | 148 ++++++++++++++++++ 5 files changed, 314 insertions(+), 6 deletions(-) create mode 100644 packages/mcp-server/src/tools/__tests__/reports.test.ts create mode 100644 packages/mcp-server/src/tools/reports.ts diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 488466cf5..39c74aca6 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -37,6 +37,9 @@ payload (spec §10.2): `VALIDATION_ERROR`, `AUTHORIZATION_ERROR`, `UPSTREAM_ERRO Deterministically sorted (name, then id) and size-capped (≤ 500). - **`accounter_list_tax_categories`** — list tax categories (id, name, IRS code, bookkeeping sort code, active flag), optionally filtered by name or active status. Same deterministic sort + cap. +- **`accounter_balance_report`** — read-only balance report (transactions) for one of your + businesses over a bounded date range (≤ 366 days). Requires `business_owner`/`accountant` role; + rows are capped at 500 with a `truncated` flag. ## Upstream GraphQL client diff --git a/packages/mcp-server/src/tools/__tests__/reports.test.ts b/packages/mcp-server/src/tools/__tests__/reports.test.ts new file mode 100644 index 000000000..b209a5307 --- /dev/null +++ b/packages/mcp-server/src/tools/__tests__/reports.test.ts @@ -0,0 +1,148 @@ +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 { executeRegisteredTool } from '../execute.js'; +import { balanceReportTool, MAX_REPORT_ROWS } from '../reports.js'; + +function authContext(businessIds: string[], roles: string[] = ['accountant']): McpAuthContext { + const principal: AuthPrincipal = { + subject: 'user-1', + issuer: 'https://tenant.auth0.com/', + audience: 'aud', + scopes: roles, + email: null, + expiresAt: undefined, + claims: { sub: 'user-1' }, + }; + return buildAuthContext( + principal, + businessIds.map(businessId => ({ businessId, roleId: 'accountant' })), + ); +} + +function clientReturning(rows: 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: { transactionsForBalanceReport: rows } }), + } as unknown as Response; + }); + return new UpstreamGraphQLClient({ + endpoint: 'http://localhost:4000/graphql', + timeoutMs: 1000, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); +} + +function row(id: string) { + return { + id, + chargeId: `charge-${id}`, + date: '2026-01-05', + isFee: false, + description: 'x', + amount: { raw: 10, formatted: '₪10', currency: 'ILS' }, + }; +} + +const run = (client: UpstreamGraphQLClient, auth: McpAuthContext, rawArgs: unknown) => + executeRegisteredTool({ + tool: balanceReportTool, + rawArgs, + auth, + correlationId: 'c', + client, + authorization: 'Bearer t', + }); + +const validArgs = { businessId: 'b1', fromDate: '2026-01-01', toDate: '2026-03-01' }; + +describe('balanceReportTool — valid report', () => { + it('returns normalized rows scoped to the requested business (ownerId)', async () => { + let sent: unknown; + const client = clientReturning([row('t1')], body => (sent = body)); + const result = await run(client, authContext(['b1', 'b2']), validArgs); + + expect(result.isError).toBeUndefined(); + expect((sent as { variables: { ownerId: string } }).variables.ownerId).toBe('b1'); + const structured = result.structuredContent as { rows: unknown[]; rowCount: number; businessId: string }; + expect(structured.businessId).toBe('b1'); + expect(structured.rowCount).toBe(1); + expect(structured.rows).toEqual([ + { + id: 't1', + chargeId: 'charge-t1', + date: '2026-01-05', + isFee: false, + description: 'x', + amount: { value: 10, formatted: '₪10', currency: 'ILS' }, + }, + ]); + }); +}); + +describe('balanceReportTool — invalid range', () => { + it('rejects an inverted date range', async () => { + const client = clientReturning([]); + const result = await run(client, authContext(['b1']), { + businessId: 'b1', + fromDate: '2026-03-01', + toDate: '2026-01-01', + }); + expect(result.isError).toBe(true); + expect((result.structuredContent as { code: string }).code).toBe('VALIDATION_ERROR'); + }); + + it('rejects a range wider than the cap', async () => { + const client = clientReturning([]); + const result = await run(client, authContext(['b1']), { + businessId: 'b1', + fromDate: '2024-01-01', + toDate: '2026-01-01', + }); + expect(result.isError).toBe(true); + expect((result.structuredContent as { message: string }).message).toMatch(/must not exceed/); + }); + + it('rejects a bad date format', async () => { + const client = clientReturning([]); + const result = await run(client, authContext(['b1']), { + businessId: 'b1', + fromDate: '2026/01/01', + toDate: '2026-02-01', + }); + expect(result.isError).toBe(true); + expect((result.structuredContent as { code: string }).code).toBe('VALIDATION_ERROR'); + }); +}); + +describe('balanceReportTool — oversized results', () => { + it('caps rows at MAX_REPORT_ROWS and flags truncation', async () => { + const many = Array.from({ length: MAX_REPORT_ROWS + 5 }, (_, i) => row(`t${i}`)); + const client = clientReturning(many); + const result = await run(client, authContext(['b1']), validArgs); + const structured = result.structuredContent as { rows: unknown[]; rowCount: number; truncated: boolean }; + expect(structured.rows).toHaveLength(MAX_REPORT_ROWS); + expect(structured.rowCount).toBe(MAX_REPORT_ROWS + 5); + expect(structured.truncated).toBe(true); + }); +}); + +describe('balanceReportTool — authorization', () => { + it('denies a caller without the required role', async () => { + const client = clientReturning([]); + const result = await run(client, authContext(['b1'], ['read:charges']), validArgs); + expect(result.isError).toBe(true); + expect((result.structuredContent as { code: string }).code).toBe('AUTHORIZATION_ERROR'); + }); + + it('denies a business outside the caller memberships', async () => { + const client = clientReturning([]); + const result = await run(client, authContext(['b2']), validArgs); + expect(result.isError).toBe(true); + expect((result.structuredContent as { code: string }).code).toBe('AUTHORIZATION_ERROR'); + }); +}); diff --git a/packages/mcp-server/src/tools/execute.ts b/packages/mcp-server/src/tools/execute.ts index 8946300b0..aee799ea3 100644 --- a/packages/mcp-server/src/tools/execute.ts +++ b/packages/mcp-server/src/tools/execute.ts @@ -68,13 +68,20 @@ export function toolErrorResult(details: ToolErrorDetails): ToolResult { }; } -/** Optional per-tool convention: an input field carrying scope narrowing. */ +/** + * Optional per-tool convention: input fields carrying scope narrowing. Either a + * `businessIds` array or a singular `businessId` string is honored. + */ 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[]; - } + if (!input || typeof input !== 'object') { + return undefined; + } + const record = input as { businessIds?: unknown; businessId?: unknown }; + if (Array.isArray(record.businessIds) && record.businessIds.every(id => typeof id === 'string')) { + return record.businessIds as string[]; + } + if (typeof record.businessId === 'string') { + return [record.businessId]; } return undefined; } diff --git a/packages/mcp-server/src/tools/registry-instance.ts b/packages/mcp-server/src/tools/registry-instance.ts index 9ddf6261e..c9aeb88aa 100644 --- a/packages/mcp-server/src/tools/registry-instance.ts +++ b/packages/mcp-server/src/tools/registry-instance.ts @@ -1,6 +1,7 @@ import { searchChargesTool } from './charges.js'; import { listTagsTool, listTaxCategoriesTool } from './lookups.js'; import { ToolRegistry } from './registry.js'; +import { balanceReportTool } from './reports.js'; /** * The process-wide registry of curated production tools. Tools register here as @@ -11,3 +12,4 @@ export const toolRegistry = new ToolRegistry(); toolRegistry.register(searchChargesTool); toolRegistry.register(listTagsTool); toolRegistry.register(listTaxCategoriesTool); +toolRegistry.register(balanceReportTool); diff --git a/packages/mcp-server/src/tools/reports.ts b/packages/mcp-server/src/tools/reports.ts new file mode 100644 index 000000000..9d377d794 --- /dev/null +++ b/packages/mcp-server/src/tools/reports.ts @@ -0,0 +1,148 @@ +import { z } from 'zod'; +import { ToolInputError } from './execute.js'; +import type { ToolDefinition, ToolExecutionContext, ToolResult } from './registry.js'; + +/** + * Tool 3: a selected read-only report (spec §8.2). + * + * Phase 1 exposes one report — the balance report — for a single authorized + * business over a bounded date range. Output rows are capped to avoid large + * unbounded payloads. Role-gated to business owners / accountants. + */ + +export const BALANCE_REPORT_TOOL_NAME = 'accounter_balance_report'; + +/** Bounds keeping the payload deterministic and small (spec §9.1, §9.3). */ +export const MAX_REPORT_DATE_RANGE_DAYS = 366; +export const MAX_REPORT_ROWS = 500; + +const TIMELESS_DATE = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be in YYYY-MM-DD format'); + +const balanceReportInput = z.object({ + businessId: z + .string() + .min(1) + .describe('The business to report on (must be one of your memberships).'), + fromDate: TIMELESS_DATE.describe('Start of the reporting period (YYYY-MM-DD).'), + toDate: TIMELESS_DATE.describe('End of the reporting period (YYYY-MM-DD).'), + reportType: z + .enum(['BALANCE']) + .optional() + .default('BALANCE') + .describe('Report type. Only BALANCE is supported in phase 1.'), +}); + +type BalanceReportInput = z.infer; + +const BALANCE_REPORT_QUERY = /* GraphQL */ ` + query McpBalanceReport($fromDate: TimelessDate!, $toDate: TimelessDate!, $ownerId: UUID) { + transactionsForBalanceReport(fromDate: $fromDate, toDate: $toDate, ownerId: $ownerId) { + id + chargeId + date + isFee + description + amount { + raw + formatted + currency + } + } + } +`; + +interface RawBalanceRow { + id: string; + chargeId: string; + date: string; + isFee: boolean; + description: string | null; + amount: { raw: number; formatted: string; currency: string }; +} + +const DAY_MS = 24 * 60 * 60 * 1000; + +function assertDateRange(input: BalanceReportInput): void { + const from = Date.parse(input.fromDate); + const to = Date.parse(input.toDate); + if (Number.isNaN(from) || Number.isNaN(to)) { + throw new ToolInputError('Invalid fromDate/toDate'); + } + if (from > to) { + throw new ToolInputError('fromDate must be on or before toDate'); + } + if ((to - from) / DAY_MS > MAX_REPORT_DATE_RANGE_DAYS) { + throw new ToolInputError(`Date range must not exceed ${MAX_REPORT_DATE_RANGE_DAYS} days`); + } +} + +async function handler( + input: BalanceReportInput, + context: ToolExecutionContext, +): Promise { + assertDateRange(input); + + // Policy has already confirmed the requested business is within scope; use the + // narrowed scope as the single owner. Assert defensively — a business-scoped + // tool must never reach upstream without a concrete owner. + const ownerId = context.readScope.businessIds[0]; + if (!ownerId) { + throw new ToolInputError('No authorized business in scope for this report'); + } + + const data = await context.client.query<{ transactionsForBalanceReport: RawBalanceRow[] }>( + { + query: BALANCE_REPORT_QUERY, + variables: { fromDate: input.fromDate, toDate: input.toDate, ownerId }, + }, + { correlationId: context.correlationId, authorization: context.authorization }, + ); + + // Defend against a null/absent list from a nullable upstream field. + const all = data.transactionsForBalanceReport ?? []; + const truncated = all.length > MAX_REPORT_ROWS; + const rows = all.slice(0, MAX_REPORT_ROWS).map(row => ({ + id: row.id, + chargeId: row.chargeId, + date: row.date, + isFee: row.isFee, + description: row.description, + amount: { + value: row.amount.raw, + formatted: row.amount.formatted, + currency: row.amount.currency, + }, + })); + + return { + content: [ + { + type: 'text', + text: `Balance report for ${input.fromDate}–${input.toDate}: ${all.length} transaction(s)${ + truncated ? ` (showing first ${MAX_REPORT_ROWS})` : '' + }.`, + }, + ], + structuredContent: { + reportType: input.reportType, + businessId: ownerId, + period: { fromDate: input.fromDate, toDate: input.toDate }, + rows, + rowCount: all.length, + truncated, + }, + }; +} + +export const balanceReportTool: ToolDefinition = { + name: BALANCE_REPORT_TOOL_NAME, + description: + 'Generate a read-only balance report (transactions) for one of your businesses over a bounded date range. Requires business owner or accountant role.', + inputSchema: balanceReportInput, + policy: { + requiredRoles: ['business_owner', 'accountant'], + requiresBusinessScope: true, + dataClassification: 'business', + }, + handler, +}; From c874b67c3610b520ddd298afa01999f589218def Mon Sep 17 00:00:00 2001 From: Gil Gardosh Date: Thu, 30 Jul 2026 09:37:28 +0300 Subject: [PATCH 28/38] feat(mcp-server): output shaping & truncation framework (MCP Prompt 16) (#4017) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mcp-server): output shaping & truncation framework (Prompt 16) Add a centralized formatter enforcing bounded, valid tool output (spec §9.3). - src/tools/output.ts: shapeListResult caps the serialized payload (MAX_TOOL_RESULT_BYTES) by dropping whole trailing items via binary search (never invalid JSON), reports returnedCount/totalCount/truncated, and adds a continuation hint (payload_size vs result_cap) when not all results are shown - retrofit all three tools (charges, tags/tax-categories, balance report) to build their results through the shared formatter; per-tool ad-hoc truncated flags removed in favor of the unified fields - unit tests for within-limit, result-cap continuation, payload-size guard (whole-item drops, valid JSON, prefix preserved, zero-fit), plus updated tool tests for the new output shape Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo * fix(mcp-server): harden shapeListResult output shaping Address review feedback on output.ts: - Normalize `total` to never be less than `items.length`, so a caller passing a too-small `total` can't produce `totalCount < returnedCount` or a wrong `truncated` flag. - Spread `extra` before the framework-owned keys so a colliding `extra` key can never overwrite the items array or the counts. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo --------- Co-authored-by: Claude --- packages/mcp-server/README.md | 5 + .../src/tools/__tests__/charges.test.ts | 7 +- .../src/tools/__tests__/lookups.test.ts | 17 ++- .../src/tools/__tests__/output.test.ts | 91 +++++++++++++ .../src/tools/__tests__/reports.test.ts | 22 +++- packages/mcp-server/src/tools/charges.ts | 21 +-- packages/mcp-server/src/tools/lookups.ts | 65 ++++------ packages/mcp-server/src/tools/output.ts | 122 ++++++++++++++++++ packages/mcp-server/src/tools/reports.ts | 26 ++-- 9 files changed, 303 insertions(+), 73 deletions(-) create mode 100644 packages/mcp-server/src/tools/__tests__/output.test.ts create mode 100644 packages/mcp-server/src/tools/output.ts diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 39c74aca6..f43bb10ec 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -29,6 +29,11 @@ failures to a tool result with `isError` and a `{ code, message, correlationId, payload (spec §10.2): `VALIDATION_ERROR`, `AUTHORIZATION_ERROR`, `UPSTREAM_ERROR`, `TIMEOUT_ERROR`, `INTERNAL_ERROR`. +List-producing tools build their output through a shared formatter (`src/tools/output.ts`) that caps +the serialized payload (dropping whole trailing items — never invalid JSON), reports `returnedCount` +/ `totalCount` / `truncated`, and attaches a `continuation` hint whenever not all results were +returned (an upstream cap or the payload-size guard). + - **`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 diff --git a/packages/mcp-server/src/tools/__tests__/charges.test.ts b/packages/mcp-server/src/tools/__tests__/charges.test.ts index 9a66be75f..bd8ec555f 100644 --- a/packages/mcp-server/src/tools/__tests__/charges.test.ts +++ b/packages/mcp-server/src/tools/__tests__/charges.test.ts @@ -66,7 +66,9 @@ describe('searchChargesTool — successful read', () => { expect(result.isError).toBeUndefined(); const structured = result.structuredContent as { charges: Array<{ id: string; amount: { value: number } | null }>; - pagination: { totalRecords: number; hasNextPage: boolean }; + pagination: { hasNextPage: boolean }; + totalCount: number; + truncated: boolean; }; expect(structured.charges).toEqual([ { @@ -76,7 +78,8 @@ describe('searchChargesTool — successful read', () => { date: '2026-01-05', }, ]); - expect(structured.pagination.totalRecords).toBe(1); + expect(structured.totalCount).toBe(1); + expect(structured.truncated).toBe(false); expect(structured.pagination.hasNextPage).toBe(false); }); diff --git a/packages/mcp-server/src/tools/__tests__/lookups.test.ts b/packages/mcp-server/src/tools/__tests__/lookups.test.ts index 8b59ffcf7..1f3593b52 100644 --- a/packages/mcp-server/src/tools/__tests__/lookups.test.ts +++ b/packages/mcp-server/src/tools/__tests__/lookups.test.ts @@ -57,17 +57,26 @@ describe('listTagsTool', () => { it('filters by nameContains (case-insensitive)', async () => { const result = await runTool(listTagsTool, client(), authContext(['b1']), { nameContains: 'an' }); - const structured = result.structuredContent as { tags: Array<{ name: string }>; total: number }; + const structured = result.structuredContent as { + tags: Array<{ name: string }>; + totalCount: number; + }; expect(structured.tags.map(t => t.name)).toEqual(['Banana']); - expect(structured.total).toBe(1); + expect(structured.totalCount).toBe(1); }); it('caps results and flags truncation', async () => { const result = await runTool(listTagsTool, client(), authContext(['b1']), { limit: 2 }); - const structured = result.structuredContent as { tags: unknown[]; total: number; truncated: boolean }; + const structured = result.structuredContent as { + tags: unknown[]; + totalCount: number; + truncated: boolean; + continuation: { reason: string }; + }; expect(structured.tags).toHaveLength(2); - expect(structured.total).toBe(3); + expect(structured.totalCount).toBe(3); expect(structured.truncated).toBe(true); + expect(structured.continuation.reason).toBe('result_cap'); }); it('enforces business scope (denies a caller with no memberships)', async () => { diff --git a/packages/mcp-server/src/tools/__tests__/output.test.ts b/packages/mcp-server/src/tools/__tests__/output.test.ts new file mode 100644 index 000000000..72516f4d5 --- /dev/null +++ b/packages/mcp-server/src/tools/__tests__/output.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; +import { MAX_TOOL_RESULT_BYTES, shapeListResult } from '../output.js'; + +function bytes(value: unknown): number { + return Buffer.byteLength(JSON.stringify(value), 'utf8'); +} + +describe('shapeListResult — within limits', () => { + it('returns all items, untruncated, with counts', () => { + const result = shapeListResult({ items: [{ a: 1 }, { a: 2 }], itemsKey: 'things' }); + const structured = result.structuredContent as { + things: unknown[]; + returnedCount: number; + totalCount: number; + truncated: boolean; + continuation?: unknown; + }; + expect(structured.things).toHaveLength(2); + expect(structured.returnedCount).toBe(2); + expect(structured.totalCount).toBe(2); + expect(structured.truncated).toBe(false); + expect(structured.continuation).toBeUndefined(); + }); + + it('merges extra fields and uses a custom summary', () => { + const result = shapeListResult({ + items: [{ a: 1 }], + itemsKey: 'things', + extra: { page: 2 }, + summarize: (shown, total) => `${shown}/${total}`, + }); + const structured = result.structuredContent as { page: number }; + expect(structured.page).toBe(2); + expect(result.content[0].text).toBe('1/1'); + }); +}); + +describe('shapeListResult — result cap (total > items)', () => { + it('marks truncated with a result_cap continuation when more exist upstream', () => { + const result = shapeListResult({ items: [{ a: 1 }], itemsKey: 'things', total: 10 }); + const structured = result.structuredContent as { + truncated: boolean; + continuation: { reason: string; returnedCount: number; totalCount: number }; + }; + expect(structured.truncated).toBe(true); + expect(structured.continuation.reason).toBe('result_cap'); + expect(structured.continuation.returnedCount).toBe(1); + expect(structured.continuation.totalCount).toBe(10); + }); +}); + +describe('shapeListResult — payload-size guard', () => { + it('drops whole trailing items to fit the byte cap and stays valid JSON', () => { + // Each item is ~1KB; cap forces dropping some. + const items = Array.from({ length: 100 }, (_, i) => ({ id: i, blob: 'x'.repeat(1000) })); + const maxBytes = 20_000; + const result = shapeListResult({ items, itemsKey: 'rows', maxBytes }); + + const structured = result.structuredContent as { + rows: Array<{ id: number }>; + returnedCount: number; + totalCount: number; + truncated: boolean; + continuation: { reason: string }; + }; + // Fits under the cap. + expect(bytes(structured)).toBeLessThanOrEqual(maxBytes); + // Dropped some, but kept whole items (a prefix of the input). + expect(structured.rows.length).toBeGreaterThan(0); + expect(structured.rows.length).toBeLessThan(100); + expect(structured.rows.map(r => r.id)).toEqual( + Array.from({ length: structured.rows.length }, (_, i) => i), + ); + expect(structured.returnedCount).toBe(structured.rows.length); + expect(structured.totalCount).toBe(100); + expect(structured.truncated).toBe(true); + expect(structured.continuation.reason).toBe('payload_size'); + }); + + it('exposes a sane default byte cap', () => { + expect(MAX_TOOL_RESULT_BYTES).toBeGreaterThan(0); + }); + + it('returns zero items when even a single item cannot fit', () => { + const items = [{ blob: 'x'.repeat(10_000) }]; + const result = shapeListResult({ items, itemsKey: 'rows', maxBytes: 100 }); + const structured = result.structuredContent as { rows: unknown[]; returnedCount: number }; + expect(structured.rows).toHaveLength(0); + expect(structured.returnedCount).toBe(0); + }); +}); diff --git a/packages/mcp-server/src/tools/__tests__/reports.test.ts b/packages/mcp-server/src/tools/__tests__/reports.test.ts index b209a5307..e163d6d0c 100644 --- a/packages/mcp-server/src/tools/__tests__/reports.test.ts +++ b/packages/mcp-server/src/tools/__tests__/reports.test.ts @@ -68,9 +68,13 @@ describe('balanceReportTool — valid report', () => { expect(result.isError).toBeUndefined(); expect((sent as { variables: { ownerId: string } }).variables.ownerId).toBe('b1'); - const structured = result.structuredContent as { rows: unknown[]; rowCount: number; businessId: string }; + const structured = result.structuredContent as { + rows: unknown[]; + totalCount: number; + businessId: string; + }; expect(structured.businessId).toBe('b1'); - expect(structured.rowCount).toBe(1); + expect(structured.totalCount).toBe(1); expect(structured.rows).toEqual([ { id: 't1', @@ -124,9 +128,17 @@ describe('balanceReportTool — oversized results', () => { const many = Array.from({ length: MAX_REPORT_ROWS + 5 }, (_, i) => row(`t${i}`)); const client = clientReturning(many); const result = await run(client, authContext(['b1']), validArgs); - const structured = result.structuredContent as { rows: unknown[]; rowCount: number; truncated: boolean }; - expect(structured.rows).toHaveLength(MAX_REPORT_ROWS); - expect(structured.rowCount).toBe(MAX_REPORT_ROWS + 5); + const structured = result.structuredContent as { + rows: unknown[]; + totalCount: number; + truncated: boolean; + }; + // The in-tool row cap bounds items before serialization; the shared + // payload guard may trim further. Either way the result is truncated and + // reports the true upstream total. + expect(structured.rows.length).toBeGreaterThan(0); + expect(structured.rows.length).toBeLessThanOrEqual(MAX_REPORT_ROWS); + expect(structured.totalCount).toBe(MAX_REPORT_ROWS + 5); expect(structured.truncated).toBe(true); }); }); diff --git a/packages/mcp-server/src/tools/charges.ts b/packages/mcp-server/src/tools/charges.ts index bbb392906..6444d7916 100644 --- a/packages/mcp-server/src/tools/charges.ts +++ b/packages/mcp-server/src/tools/charges.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { ToolInputError } from './execute.js'; +import { shapeListResult } from './output.js'; import type { ToolDefinition, ToolExecutionContext, ToolResult } from './registry.js'; /** @@ -196,19 +197,19 @@ async function handler( 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 }, - }; + return shapeListResult({ + items: charges, + itemsKey: 'charges', + total: pageInfo.totalRecords, + extra: { pagination }, + summarize: (shown, total) => + total === 0 + ? 'No charges matched the given filters.' + : `Found ${total} charge(s); showing ${shown} on page ${pagination.page} of ${pagination.totalPages}.`, + }); } export const searchChargesTool: ToolDefinition = { diff --git a/packages/mcp-server/src/tools/lookups.ts b/packages/mcp-server/src/tools/lookups.ts index 1bc6c65e9..aa7f20f84 100644 --- a/packages/mcp-server/src/tools/lookups.ts +++ b/packages/mcp-server/src/tools/lookups.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { shapeListResult } from './output.js'; import type { ToolDefinition, ToolExecutionContext, ToolResult } from './registry.js'; /** @@ -40,19 +41,17 @@ function byNameThenId(a: { name: string; id: string }, b: { name: string; id: st return NAME_COLLATOR.compare(a.name, b.name) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0); } -function applyFilterSortCap( +function filterSortCap( rows: T[], search: string | undefined, max: number, -): { rows: T[]; total: number; truncated: boolean } { +): { rows: T[]; total: number } { const needle = search?.toLowerCase(); const filtered = needle ? rows.filter(row => row.name.toLowerCase().includes(needle)) : rows; const sorted = [...filtered].sort(byNameThenId); - return { - rows: sorted.slice(0, max), - total: filtered.length, - truncated: filtered.length > max, - }; + // `total` is the full match count; the byte-guard/continuation is applied by + // shapeListResult against this total. + return { rows: sorted.slice(0, max), total: filtered.length }; } // --------------------------------------------------------------------------- @@ -87,26 +86,20 @@ async function listTagsHandler( { correlationId: context.correlationId, authorization: context.authorization }, ); - const { rows, total, truncated } = applyFilterSortCap( - data.allTags, - input.nameContains, - input.limit, - ); + const { rows, total } = filterSortCap(data.allTags, input.nameContains, input.limit); const tags = rows.map(tag => ({ id: tag.id, name: tag.name, namePath: tag.namePath ?? [tag.name], })); - return { - content: [ - { - type: 'text', - text: `Found ${total} ${total === 1 ? 'tag' : 'tags'}${truncated ? ' (truncated)' : ''}.`, - }, - ], - structuredContent: { tags, total, truncated }, - }; + return shapeListResult({ + items: tags, + itemsKey: 'tags', + total, + summarize: (_shown, count, truncated) => + `Found ${count} ${count === 1 ? 'tag' : 'tags'}${truncated ? ' (truncated)' : ''}.`, + }); } export const listTagsTool: ToolDefinition = { @@ -166,23 +159,21 @@ async function listTaxCategoriesHandler( ? data.taxCategories.filter(category => category.isActive) : data.taxCategories; // `rows` are already `RawTaxCategory` with exactly the fields we expose. - const { - rows: taxCategories, + const { rows: taxCategories, total } = filterSortCap( + activeFiltered, + input.nameContains, + input.limit, + ); + + return shapeListResult({ + items: taxCategories, + itemsKey: 'taxCategories', total, - truncated, - } = applyFilterSortCap(activeFiltered, input.nameContains, input.limit); - - return { - content: [ - { - type: 'text', - text: `Found ${total} tax ${total === 1 ? 'category' : 'categories'}${ - truncated ? ' (truncated)' : '' - }.`, - }, - ], - structuredContent: { taxCategories, total, truncated }, - }; + summarize: (_shown, count, truncated) => + `Found ${count} tax ${count === 1 ? 'category' : 'categories'}${ + truncated ? ' (truncated)' : '' + }.`, + }); } export const listTaxCategoriesTool: ToolDefinition = { diff --git a/packages/mcp-server/src/tools/output.ts b/packages/mcp-server/src/tools/output.ts new file mode 100644 index 000000000..d65b4ee62 --- /dev/null +++ b/packages/mcp-server/src/tools/output.ts @@ -0,0 +1,122 @@ +import type { ToolResult } from './registry.js'; + +/** + * Centralized output shaping and truncation for all tools (spec §9.3). + * + * List-producing tools build their result through {@link shapeListResult}. It + * enforces a maximum serialized payload size by dropping whole trailing items + * (never cutting a JSON structure mid-object), reports how many items were + * returned versus available, and attaches a continuation hint whenever the + * caller is not seeing everything (either an upstream cap or the payload guard). + */ + +/** Max serialized size of a tool result's structured content, in bytes. */ +export const MAX_TOOL_RESULT_BYTES = 60_000; + +function byteLength(value: string): number { + return Buffer.byteLength(value, 'utf8'); +} + +/** Continuation hint returned when results are truncated. */ +export interface ContinuationHint { + reason: 'payload_size' | 'result_cap'; + returnedCount: number; + totalCount: number; + hint: string; +} + +export interface ShapeListParams { + /** Items to return (already filtered/normalized; may itself be upstream-capped). */ + items: readonly T[]; + /** Key under which the items are placed in structured content (e.g. `charges`). */ + itemsKey: string; + /** + * Total available upstream. Defaults to `items.length`. When greater than the + * number ultimately returned, the result is marked truncated with a hint. + */ + total?: number; + /** Extra domain fields merged into structured content (pagination, period, …). */ + extra?: Record; + /** Build the human-readable summary line. */ + summarize?: (shown: number, total: number, truncated: boolean) => string; + /** Override the byte cap (mainly for tests). */ + maxBytes?: number; +} + +function defaultSummary(shown: number, total: number, truncated: boolean): string { + if (total === 0) { + return 'No results.'; + } + return `Returning ${shown} of ${total} result(s)${truncated ? ' (truncated)' : ''}.`; +} + +/** + * Largest prefix length `n` of `items` whose shaped structured content fits + * within `maxBytes`. Uses binary search so it is deterministic and fast. + */ +function fittingCount( + build: (n: number) => Record, + itemCount: number, + maxBytes: number, +): number { + if (byteLength(JSON.stringify(build(itemCount))) <= maxBytes) { + return itemCount; + } + let low = 0; + let high = itemCount; + while (low < high) { + const mid = Math.ceil((low + high) / 2); + if (byteLength(JSON.stringify(build(mid))) <= maxBytes) { + low = mid; + } else { + high = mid - 1; + } + } + return low; +} + +/** + * Shape a list of items into a bounded, valid {@link ToolResult}. Never emits + * invalid JSON — items are dropped whole from the end when the payload guard + * trips. + */ +export function shapeListResult(params: ShapeListParams): ToolResult { + const maxBytes = params.maxBytes ?? MAX_TOOL_RESULT_BYTES; + // Never let a caller-supplied `total` fall below the number of items on hand; + // otherwise `totalCount` could be < `returnedCount` and `truncated` would be + // computed incorrectly. + const total = Math.max(params.total ?? params.items.length, params.items.length); + + const structuredFor = (shown: number): Record => { + const truncated = shown < total; + // Spread `extra` first so the framework-owned keys below always win a name + // collision — `extra` can never clobber the items array or the counts. + const structured: Record = { + ...params.extra, + [params.itemsKey]: params.items.slice(0, shown), + returnedCount: shown, + totalCount: total, + truncated, + }; + if (truncated) { + const continuation: ContinuationHint = { + reason: shown < params.items.length ? 'payload_size' : 'result_cap', + returnedCount: shown, + totalCount: total, + hint: 'Not all results were returned. Narrow your filters or request a smaller/next page to see more.', + }; + structured.continuation = continuation; + } + return structured; + }; + + const shown = fittingCount(structuredFor, params.items.length, maxBytes); + const structured = structuredFor(shown); + const truncated = structured.truncated === true; + const summarize = params.summarize ?? defaultSummary; + + return { + content: [{ type: 'text', text: summarize(shown, total, truncated) }], + structuredContent: structured, + }; +} diff --git a/packages/mcp-server/src/tools/reports.ts b/packages/mcp-server/src/tools/reports.ts index 9d377d794..5074b9bd9 100644 --- a/packages/mcp-server/src/tools/reports.ts +++ b/packages/mcp-server/src/tools/reports.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { ToolInputError } from './execute.js'; +import { shapeListResult } from './output.js'; import type { ToolDefinition, ToolExecutionContext, ToolResult } from './registry.js'; /** @@ -100,7 +101,6 @@ async function handler( // Defend against a null/absent list from a nullable upstream field. const all = data.transactionsForBalanceReport ?? []; - const truncated = all.length > MAX_REPORT_ROWS; const rows = all.slice(0, MAX_REPORT_ROWS).map(row => ({ id: row.id, chargeId: row.chargeId, @@ -114,24 +114,20 @@ async function handler( }, })); - return { - content: [ - { - type: 'text', - text: `Balance report for ${input.fromDate}–${input.toDate}: ${all.length} transaction(s)${ - truncated ? ` (showing first ${MAX_REPORT_ROWS})` : '' - }.`, - }, - ], - structuredContent: { + return shapeListResult({ + items: rows, + itemsKey: 'rows', + total: all.length, + extra: { reportType: input.reportType, businessId: ownerId, period: { fromDate: input.fromDate, toDate: input.toDate }, - rows, - rowCount: all.length, - truncated, }, - }; + summarize: (shown, total) => + `Balance report for ${input.fromDate}–${input.toDate}: ${total} transaction(s)${ + shown < total ? ` (showing ${shown})` : '' + }.`, + }); } export const balanceReportTool: ToolDefinition = { From eb875d63b25db420a74e5ad8fda3164d28b29d77 Mon Sep 17 00:00:00 2001 From: Gil Gardosh Date: Thu, 30 Jul 2026 09:46:47 +0300 Subject: [PATCH 29/38] feat(mcp-server): unified error taxonomy & mapper (MCP Prompt 17) (#4021) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mcp-server): unified error taxonomy and mapper (Prompt 17) Centralize all failure handling in one taxonomy (docs/mcp/spec.md §10.2). - src/errors/taxonomy.ts: 7 machine codes (VALIDATION/AUTHENTICATION/ AUTHORIZATION/UPSTREAM/TIMEOUT/RATE_LIMIT/INTERNAL); McpError base with ToolInputError and RateLimitError; toErrorPayload maps every source (McpError, UpstreamError, TokenVerificationError, unknown) into a business-safe { code, message, correlationId, retryable, issues?, retryAfterMs? }; unknown errors sanitized to INTERNAL_ERROR (no leaks) - refactor executeRegisteredTool to build/render errors through the taxonomy; ToolInputError re-exported from execute for tool handlers - unit tests for every mapping branch, retryability defaults, sanitization, and tool-result rendering Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo * fix(mcp-server): address error-taxonomy review feedback - Drop the unnecessary double-cast on toToolErrorResult's structuredContent (ToolResult.structuredContent is already `unknown`). - Actually exercise the `retryable` override in the errorPayload test: pass `retryable: true` on a normally-non-retryable VALIDATION_ERROR and assert the override wins. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo --------- Co-authored-by: Claude --- packages/mcp-server/README.md | 11 +- .../src/errors/__tests__/taxonomy.test.ts | 124 +++++++++++++ packages/mcp-server/src/errors/taxonomy.ts | 167 ++++++++++++++++++ packages/mcp-server/src/tools/execute.ts | 122 ++++--------- 4 files changed, 328 insertions(+), 96 deletions(-) create mode 100644 packages/mcp-server/src/errors/__tests__/taxonomy.test.ts create mode 100644 packages/mcp-server/src/errors/taxonomy.ts diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index f43bb10ec..e3eaca7fc 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -24,10 +24,13 @@ 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`. +`tools/call` for a registered tool runs input validation → authorization policy → handler. Every +failure is normalized through a single error taxonomy (`src/errors/taxonomy.ts`) and returned as a +tool result with `isError` and a `{ code, message, correlationId, retryable }` payload (plus +`issues` for validation and `retryAfterMs` for rate limiting). Machine codes (spec §10.2): +`VALIDATION_ERROR`, `AUTHENTICATION_ERROR`, `AUTHORIZATION_ERROR`, `UPSTREAM_ERROR`, +`TIMEOUT_ERROR`, `RATE_LIMIT_ERROR`, `INTERNAL_ERROR`. Unexpected errors map to a sanitized +`INTERNAL_ERROR` — stack traces and internal details are never leaked. List-producing tools build their output through a shared formatter (`src/tools/output.ts`) that caps the serialized payload (dropping whole trailing items — never invalid JSON), reports `returnedCount` diff --git a/packages/mcp-server/src/errors/__tests__/taxonomy.test.ts b/packages/mcp-server/src/errors/__tests__/taxonomy.test.ts new file mode 100644 index 000000000..7fe7cd595 --- /dev/null +++ b/packages/mcp-server/src/errors/__tests__/taxonomy.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest'; +import { TokenVerificationError } from '../../auth/token.js'; +import { UpstreamError } from '../../upstream/graphql-client.js'; +import { + DEFAULT_RETRYABLE, + errorPayload, + isInternalError, + McpError, + RateLimitError, + ToolInputError, + toErrorPayload, + toToolErrorResult, +} from '../taxonomy.js'; + +const CID = 'corr-1'; + +describe('errorPayload', () => { + it('defaults retryability by code', () => { + expect(errorPayload('TIMEOUT_ERROR', 'timed out', CID).retryable).toBe(true); + expect(errorPayload('VALIDATION_ERROR', 'bad', CID).retryable).toBe(false); + }); + + it('honors an explicit retryable override and includes issues', () => { + const payload = errorPayload('VALIDATION_ERROR', 'bad', CID, { + retryable: true, + issues: [{ path: 'x', message: 'required' }], + }); + // VALIDATION_ERROR defaults to non-retryable; the explicit override wins. + expect(payload.retryable).toBe(true); + expect(payload.issues).toEqual([{ path: 'x', message: 'required' }]); + }); + + it('omits empty issues and absent retryAfterMs', () => { + const payload = errorPayload('INTERNAL_ERROR', 'x', CID, { issues: [] }); + expect('issues' in payload).toBe(false); + expect('retryAfterMs' in payload).toBe(false); + }); +}); + +describe('toErrorPayload — mapping every source', () => { + it('passes through an McpError with its fields', () => { + const payload = toErrorPayload( + new McpError('AUTHORIZATION_ERROR', 'nope', { retryable: false }), + CID, + ); + expect(payload).toMatchObject({ code: 'AUTHORIZATION_ERROR', message: 'nope', retryable: false }); + }); + + it('maps ToolInputError to VALIDATION_ERROR with issues', () => { + const payload = toErrorPayload(new ToolInputError('bad range', [{ path: 'to', message: 'x' }]), CID); + expect(payload.code).toBe('VALIDATION_ERROR'); + expect(payload.issues).toEqual([{ path: 'to', message: 'x' }]); + }); + + it('maps RateLimitError to RATE_LIMIT_ERROR with retryAfterMs', () => { + const payload = toErrorPayload(new RateLimitError('slow down', 5000), CID); + expect(payload).toMatchObject({ code: 'RATE_LIMIT_ERROR', retryable: true, retryAfterMs: 5000 }); + }); + + it('maps UpstreamError preserving its code and retryability', () => { + expect(toErrorPayload(new UpstreamError('TIMEOUT_ERROR', 'slow', true), CID)).toMatchObject({ + code: 'TIMEOUT_ERROR', + retryable: true, + }); + expect(toErrorPayload(new UpstreamError('UPSTREAM_ERROR', 'boom', false), CID)).toMatchObject({ + code: 'UPSTREAM_ERROR', + retryable: false, + }); + }); + + it('maps a token verification failure to AUTHENTICATION_ERROR', () => { + const payload = toErrorPayload(new TokenVerificationError('expired'), CID); + expect(payload).toMatchObject({ code: 'AUTHENTICATION_ERROR', retryable: false }); + }); + + it('maps an unknown error to a sanitized INTERNAL_ERROR (no leak)', () => { + const payload = toErrorPayload(new Error('SELECT * FROM secrets failed at line 42'), CID); + expect(payload.code).toBe('INTERNAL_ERROR'); + expect(payload.message).not.toContain('SELECT'); + expect(payload.correlationId).toBe(CID); + }); + + it('maps a non-Error throwable to INTERNAL_ERROR', () => { + expect(toErrorPayload('boom', CID).code).toBe('INTERNAL_ERROR'); + }); +}); + +describe('isInternalError', () => { + it('is true only for unmapped errors', () => { + expect(isInternalError(new Error('x'))).toBe(true); + expect(isInternalError('x')).toBe(true); + expect(isInternalError(new McpError('AUTHORIZATION_ERROR', 'x'))).toBe(false); + expect(isInternalError(new UpstreamError('UPSTREAM_ERROR', 'x', false))).toBe(false); + expect(isInternalError(new TokenVerificationError('x'))).toBe(false); + }); +}); + +describe('toToolErrorResult', () => { + it('renders isError with a code-prefixed text and structured payload', () => { + const result = toToolErrorResult( + errorPayload('RATE_LIMIT_ERROR', 'slow down', CID, { retryAfterMs: 1000 }), + ); + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe('RATE_LIMIT_ERROR: slow down'); + expect(result.structuredContent).toMatchObject({ + code: 'RATE_LIMIT_ERROR', + message: 'slow down', + correlationId: CID, + retryable: true, + retryAfterMs: 1000, + }); + }); +}); + +describe('DEFAULT_RETRYABLE', () => { + it('marks only transient failures retryable', () => { + expect(DEFAULT_RETRYABLE.TIMEOUT_ERROR).toBe(true); + expect(DEFAULT_RETRYABLE.RATE_LIMIT_ERROR).toBe(true); + expect(DEFAULT_RETRYABLE.VALIDATION_ERROR).toBe(false); + expect(DEFAULT_RETRYABLE.AUTHENTICATION_ERROR).toBe(false); + expect(DEFAULT_RETRYABLE.AUTHORIZATION_ERROR).toBe(false); + expect(DEFAULT_RETRYABLE.INTERNAL_ERROR).toBe(false); + }); +}); diff --git a/packages/mcp-server/src/errors/taxonomy.ts b/packages/mcp-server/src/errors/taxonomy.ts new file mode 100644 index 000000000..3056a59c1 --- /dev/null +++ b/packages/mcp-server/src/errors/taxonomy.ts @@ -0,0 +1,167 @@ +import { TokenVerificationError } from '../auth/token.js'; +import type { ToolResult, ToolValidationIssue } from '../tools/registry.js'; +import { UpstreamError } from '../upstream/graphql-client.js'; + +/** + * Unified error taxonomy and mapper (spec §10.2). + * + * Every failure surfaced to a caller is normalized to one machine {@link + * McpErrorCode} with a business-safe message, the request correlation id, and a + * retryability hint. Stack traces and internal/SQL details are never included. + * All error sources (validation, auth, policy, upstream, rate limiting, and + * unexpected failures) map through {@link toErrorPayload}. + */ + +export type McpErrorCode = + | 'VALIDATION_ERROR' + | 'AUTHENTICATION_ERROR' + | 'AUTHORIZATION_ERROR' + | 'UPSTREAM_ERROR' + | 'TIMEOUT_ERROR' + | 'RATE_LIMIT_ERROR' + | 'INTERNAL_ERROR'; + +/** Default retryability per code (transient failures are retryable). */ +export const DEFAULT_RETRYABLE: Record = { + VALIDATION_ERROR: false, + AUTHENTICATION_ERROR: false, + AUTHORIZATION_ERROR: false, + UPSTREAM_ERROR: false, + TIMEOUT_ERROR: true, + RATE_LIMIT_ERROR: true, + INTERNAL_ERROR: false, +}; + +/** Normalized, business-safe error payload returned to callers. */ +export interface McpErrorPayload { + code: McpErrorCode; + /** Human-readable message safe to show end users (no internal detail). */ + message: string; + correlationId: string; + /** Whether the caller may retry the same request. */ + retryable: boolean; + /** Field-level issues for VALIDATION_ERROR. */ + issues?: ToolValidationIssue[]; + /** Suggested wait before retrying (rate limiting / backoff). */ + retryAfterMs?: number; +} + +export interface McpErrorOptions { + retryable?: boolean; + issues?: ToolValidationIssue[]; + retryAfterMs?: number; +} + +/** Base error carrying a taxonomy code. Thrown by layers that fail loudly. */ +export class McpError extends Error { + public readonly code: McpErrorCode; + public readonly retryable: boolean; + public readonly issues?: ToolValidationIssue[]; + public readonly retryAfterMs?: number; + + constructor(code: McpErrorCode, message: string, options: McpErrorOptions = {}) { + super(message); + this.name = 'McpError'; + this.code = code; + this.retryable = options.retryable ?? DEFAULT_RETRYABLE[code]; + this.issues = options.issues; + this.retryAfterMs = options.retryAfterMs; + } +} + +/** + * A domain-validation failure a handler can throw (e.g. cross-field bounds not + * expressible in the input schema). Maps to VALIDATION_ERROR. + */ +export class ToolInputError extends McpError { + constructor(message: string, issues?: ToolValidationIssue[]) { + super('VALIDATION_ERROR', message, { issues }); + this.name = 'ToolInputError'; + } +} + +/** Raised when a caller exceeds a rate limit. Maps to RATE_LIMIT_ERROR. */ +export class RateLimitError extends McpError { + constructor(message: string, retryAfterMs: number) { + super('RATE_LIMIT_ERROR', message, { retryable: true, retryAfterMs }); + this.name = 'RateLimitError'; + } +} + +const SAFE_INTERNAL_MESSAGE = 'An internal error occurred'; + +function withOptionalFields(payload: McpErrorPayload): McpErrorPayload { + const result: McpErrorPayload = { + code: payload.code, + message: payload.message, + correlationId: payload.correlationId, + retryable: payload.retryable, + }; + if (payload.issues && payload.issues.length > 0) { + result.issues = payload.issues; + } + if (payload.retryAfterMs !== undefined) { + result.retryAfterMs = payload.retryAfterMs; + } + return result; +} + +/** Build a payload from explicit fields, defaulting retryability by code. */ +export function errorPayload( + code: McpErrorCode, + message: string, + correlationId: string, + options: McpErrorOptions = {}, +): McpErrorPayload { + return withOptionalFields({ + code, + message, + correlationId, + retryable: options.retryable ?? DEFAULT_RETRYABLE[code], + issues: options.issues, + retryAfterMs: options.retryAfterMs, + }); +} + +/** + * Map any error source into the taxonomy. Known errors keep their code and + * message; anything else becomes an INTERNAL_ERROR with a generic message so no + * stack trace or internal detail leaks. + */ +export function toErrorPayload(error: unknown, correlationId: string): McpErrorPayload { + if (error instanceof McpError) { + return withOptionalFields({ + code: error.code, + message: error.message, + correlationId, + retryable: error.retryable, + issues: error.issues, + retryAfterMs: error.retryAfterMs, + }); + } + if (error instanceof UpstreamError) { + return errorPayload(error.code, error.message, correlationId, { retryable: error.retryable }); + } + if (error instanceof TokenVerificationError) { + return errorPayload('AUTHENTICATION_ERROR', error.message, correlationId); + } + return errorPayload('INTERNAL_ERROR', SAFE_INTERNAL_MESSAGE, correlationId); +} + +/** Whether an error maps to INTERNAL_ERROR (i.e. it is unexpected/unmapped). */ +export function isInternalError(error: unknown): boolean { + return !( + error instanceof McpError || + error instanceof UpstreamError || + error instanceof TokenVerificationError + ); +} + +/** Render an error payload as an MCP tool result (`isError: true`). */ +export function toToolErrorResult(payload: McpErrorPayload): ToolResult { + return { + content: [{ type: 'text', text: `${payload.code}: ${payload.message}` }], + isError: true, + structuredContent: withOptionalFields(payload), + }; +} diff --git a/packages/mcp-server/src/tools/execute.ts b/packages/mcp-server/src/tools/execute.ts index aee799ea3..292f0e022 100644 --- a/packages/mcp-server/src/tools/execute.ts +++ b/packages/mcp-server/src/tools/execute.ts @@ -1,6 +1,11 @@ import type { McpAuthContext } from '../auth/identity.js'; +import { + errorPayload, + isInternalError, + toErrorPayload, + toToolErrorResult, +} from '../errors/taxonomy.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 { @@ -8,65 +13,20 @@ import { 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). + * Curated tool execution: input validation → authorization policy → handler. * - * 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. + * Every failure is normalized through the unified error taxonomy + * ({@link toErrorPayload}) and returned as an MCP tool result with `isError` + * and a structured `{ code, message, correlationId, retryable }` payload rather + * than as a protocol-level JSON-RPC error, so the model can read it. */ -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 }), - }, - }; -} +// Re-exported so tool handlers can import the domain-validation error alongside +// the executor; the canonical definition lives in the error taxonomy. +export { ToolInputError } from '../errors/taxonomy.js'; /** * Optional per-tool convention: input fields carrying scope narrowing. Either a @@ -105,12 +65,11 @@ export async function executeRegisteredTool(params: ExecuteToolParams): Promise< // 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, - }); + return toToolErrorResult( + errorPayload('VALIDATION_ERROR', validation.error.message, correlationId, { + issues: validation.error.issues, + }), + ); } const input = validation.data; @@ -121,11 +80,9 @@ export async function executeRegisteredTool(params: ExecuteToolParams): Promise< requestedBusinessIds: requestedBusinessIds(input), }); if (!decision.allowed) { - return toolErrorResult({ - code: 'AUTHORIZATION_ERROR', - message: decision.error.message, - correlationId, - }); + return toToolErrorResult( + errorPayload('AUTHORIZATION_ERROR', decision.error.message, correlationId), + ); } // 3. Execute the handler. @@ -139,35 +96,16 @@ export async function executeRegisteredTool(params: ExecuteToolParams): Promise< 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, + // Log unexpected (unmapped) failures so bugs aren't hidden; the caller only + // ever sees the generic, sanitized INTERNAL_ERROR message. + if (isInternalError(error)) { + log('error', 'unexpected error during tool execution', { + tool: tool.name, correlationId, - retryable: error.retryable, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, }); } - // 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, - }); + return toToolErrorResult(toErrorPayload(error, correlationId)); } } From 8f21acce99d828c6998721418e49ef57f62dfe5c Mon Sep 17 00:00:00 2001 From: Gil Gardosh Date: Thu, 30 Jul 2026 09:57:47 +0300 Subject: [PATCH 30/38] feat(mcp-server): per-user/business/tool rate limiting (MCP Prompt 18) (#4022) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mcp-server): per-user/business/tool rate limiting (Prompt 18) Add an in-memory rate limiter enforced before upstream calls (docs/mcp/spec.md §11.4). - src/rate-limit/limiter.ts: fixed-window RateLimiter with an injectable clock; rateLimitKey scopes buckets to userId + sorted business scope + tool; parseRateLimitConfig reads MCP_RATE_LIMIT_CONFIG with safe defaults ({windowMs:60000,max:60}) - src/rate-limit/default-limiter.ts: memoized env-backed limiter - executeRegisteredTool enforces an optional limiter after policy, before the handler; over-limit returns RATE_LIMIT_ERROR with retryAfterMs (via the unified taxonomy); dispatch wires the process limiter - tests: burst/deny, per-key isolation, window reset, denied-not-consuming, config parsing, and end-to-end limiting through executeRegisteredTool Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo * fix(mcp-server): address rate-limiter review feedback - parseRateLimitConfig: floor `max` before the positivity check so a fractional value in (0, 1) (e.g. {"max":0.5}) falls back to the default instead of becoming max: 0, which would rate-limit every request. - Introduce a structural `RateLimiterLike` interface and depend on it from the tool executor, so an alternative limiter (e.g. shared/Redis-backed) can be swapped in without changing signatures. - Prune expired buckets periodically so the in-memory bucket map cannot grow without bound in a long-lived process with many distinct keys. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo --------- Co-authored-by: Claude --- packages/mcp-server/README.md | 5 + packages/mcp-server/src/mcp/handler.ts | 2 + .../src/rate-limit/__tests__/limiter.test.ts | 89 +++++++++++ .../src/rate-limit/default-limiter.ts | 18 +++ packages/mcp-server/src/rate-limit/limiter.ts | 149 ++++++++++++++++++ .../__tests__/rate-limit.execute.test.ts | 79 ++++++++++ packages/mcp-server/src/tools/execute.ts | 30 +++- 7 files changed, 370 insertions(+), 2 deletions(-) create mode 100644 packages/mcp-server/src/rate-limit/__tests__/limiter.test.ts create mode 100644 packages/mcp-server/src/rate-limit/default-limiter.ts create mode 100644 packages/mcp-server/src/rate-limit/limiter.ts create mode 100644 packages/mcp-server/src/tools/__tests__/rate-limit.execute.test.ts diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index e3eaca7fc..851c3be96 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -37,6 +37,11 @@ the serialized payload (dropping whole trailing items — never invalid JSON), r / `totalCount` / `truncated`, and attaches a `continuation` hint whenever not all results were returned (an upstream cap or the payload-size guard). +Each `tools/call` is rate-limited (`src/rate-limit/`) with an in-memory fixed-window counter keyed +by **user + business scope + tool**, enforced before any upstream call. Exceeding the limit returns +a `RATE_LIMIT_ERROR` with `retryAfterMs`. Limits are configured via `MCP_RATE_LIMIT_CONFIG` +(`{"windowMs":60000,"max":60}` by default). + - **`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 diff --git a/packages/mcp-server/src/mcp/handler.ts b/packages/mcp-server/src/mcp/handler.ts index 384ffdf68..0641b3b38 100644 --- a/packages/mcp-server/src/mcp/handler.ts +++ b/packages/mcp-server/src/mcp/handler.ts @@ -18,6 +18,7 @@ import { generateId, getRequestContext } from '../context.js'; import { createRequestLogger, log } from '../logger.js'; import { sendUnauthorized } from '../oauth/challenge.js'; import { protectedResourceMetadataUrl } from '../oauth/metadata.js'; +import { getRateLimiter } from '../rate-limit/default-limiter.js'; import { executeRegisteredTool } from '../tools/execute.js'; import { toolRegistry } from '../tools/registry-instance.js'; import { getUpstreamClient } from '../upstream/default-client.js'; @@ -189,6 +190,7 @@ export async function dispatchMcpRequest( correlationId: context.correlationId, authorization: context.authorization, client: getUpstreamClient(), + limiter: getRateLimiter(), }); return success(id, result); } diff --git a/packages/mcp-server/src/rate-limit/__tests__/limiter.test.ts b/packages/mcp-server/src/rate-limit/__tests__/limiter.test.ts new file mode 100644 index 000000000..236bc1f39 --- /dev/null +++ b/packages/mcp-server/src/rate-limit/__tests__/limiter.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_RATE_LIMIT, + parseRateLimitConfig, + RateLimiter, + rateLimitKey, +} from '../limiter.js'; + +describe('rateLimitKey', () => { + it('scopes the key to identity + sorted business scope + tool', () => { + expect(rateLimitKey({ userId: 'u1', toolName: 't', businessIds: ['b2', 'b1'] })).toBe( + 'u1|b1,b2|t', + ); + }); + + it('uses "none" when there is no business scope', () => { + expect(rateLimitKey({ userId: 'u1', toolName: 't', businessIds: [] })).toBe('u1|none|t'); + }); +}); + +describe('RateLimiter', () => { + it('allows a burst up to the max then denies', () => { + const limiter = new RateLimiter({ windowMs: 1000, max: 3 }, () => 0); + expect(limiter.check('k').allowed).toBe(true); + expect(limiter.check('k').allowed).toBe(true); + const third = limiter.check('k'); + expect(third.allowed).toBe(true); + expect(third.remaining).toBe(0); + + const denied = limiter.check('k'); + expect(denied.allowed).toBe(false); + expect(denied.remaining).toBe(0); + expect(denied.retryAfterMs).toBe(1000); + }); + + it('keeps separate buckets per key', () => { + const limiter = new RateLimiter({ windowMs: 1000, max: 1 }, () => 0); + expect(limiter.check('a').allowed).toBe(true); + expect(limiter.check('a').allowed).toBe(false); + expect(limiter.check('b').allowed).toBe(true); + }); + + it('resets when the window elapses', () => { + let now = 0; + const limiter = new RateLimiter({ windowMs: 1000, max: 1 }, () => now); + expect(limiter.check('k').allowed).toBe(true); + expect(limiter.check('k').allowed).toBe(false); + now = 1000; // window boundary + expect(limiter.check('k').allowed).toBe(true); + }); + + it('a denied request does not consume additional budget', () => { + let now = 0; + const limiter = new RateLimiter({ windowMs: 1000, max: 1 }, () => now); + limiter.check('k'); // allowed + limiter.check('k'); // denied + limiter.check('k'); // denied + now = 1000; + // Only one allowed in the next window (bucket count reset to 0, not carried). + expect(limiter.check('k').allowed).toBe(true); + expect(limiter.check('k').allowed).toBe(false); + }); +}); + +describe('parseRateLimitConfig', () => { + it('returns defaults for empty/absent input', () => { + expect(parseRateLimitConfig(undefined)).toEqual(DEFAULT_RATE_LIMIT); + expect(parseRateLimitConfig('')).toEqual(DEFAULT_RATE_LIMIT); + }); + + it('parses a valid JSON config', () => { + expect(parseRateLimitConfig('{"windowMs":30000,"max":10}')).toEqual({ + windowMs: 30_000, + max: 10, + }); + }); + + it('falls back to defaults on invalid JSON or values', () => { + expect(parseRateLimitConfig('not json')).toEqual(DEFAULT_RATE_LIMIT); + expect(parseRateLimitConfig('{"windowMs":-5,"max":0}')).toEqual(DEFAULT_RATE_LIMIT); + }); + + it('does not let a fractional max collapse to 0 (which would block everything)', () => { + // Math.floor(0.5) === 0; must fall back to the default rather than max: 0. + expect(parseRateLimitConfig('{"max":0.5}').max).toBe(DEFAULT_RATE_LIMIT.max); + // A value >= 1 is floored normally. + expect(parseRateLimitConfig('{"max":5.9}').max).toBe(5); + }); +}); diff --git a/packages/mcp-server/src/rate-limit/default-limiter.ts b/packages/mcp-server/src/rate-limit/default-limiter.ts new file mode 100644 index 000000000..cedae898e --- /dev/null +++ b/packages/mcp-server/src/rate-limit/default-limiter.ts @@ -0,0 +1,18 @@ +import { env } from '../config/env.js'; +import { parseRateLimitConfig, RateLimiter } from './limiter.js'; + +let cached: RateLimiter | undefined; + +/** + * The process-wide rate limiter, configured from `MCP_RATE_LIMIT_CONFIG`. + * Lazily created and memoized. + */ +export function getRateLimiter(): RateLimiter { + cached ??= new RateLimiter(parseRateLimitConfig(env.rateLimit.raw)); + return cached; +} + +/** Test-only: reset the memoized limiter. */ +export function resetRateLimiter(): void { + cached = undefined; +} diff --git a/packages/mcp-server/src/rate-limit/limiter.ts b/packages/mcp-server/src/rate-limit/limiter.ts new file mode 100644 index 000000000..0de2e0585 --- /dev/null +++ b/packages/mcp-server/src/rate-limit/limiter.ts @@ -0,0 +1,149 @@ +/** + * In-memory rate limiter (spec §11.4). + * + * A fixed-window counter keyed by authenticated identity + business scope + + * tool. Enforced before expensive upstream calls. Phase 1 uses an in-process + * store; a shared store can replace it later without changing callers. + */ + +export interface RateLimitConfig { + /** Window length in milliseconds. */ + windowMs: number; + /** Max requests allowed per key per window. */ + max: number; +} + +export const DEFAULT_RATE_LIMIT: RateLimitConfig = { windowMs: 60_000, max: 60 }; + +export interface RateLimitResult { + allowed: boolean; + limit: number; + remaining: number; + /** Milliseconds until the current window resets. */ + retryAfterMs: number; +} + +/** + * Minimal structural limiter contract. Callers depend on this rather than the + * concrete {@link RateLimiter} class so an alternative implementation (e.g. a + * shared/Redis-backed limiter) can be swapped in without signature changes. + */ +export interface RateLimiterLike { + check(key: string): RateLimitResult; +} + +interface Bucket { + count: number; + resetAt: number; +} + +/** How many `check` calls between opportunistic sweeps of expired buckets. */ +const SWEEP_EVERY = 1000; + +/** Build a limiter key scoped to identity + business scope + tool. */ +export function rateLimitKey(params: { + userId: string; + toolName: string; + businessIds: readonly string[]; +}): string { + const scope = params.businessIds.length > 0 ? [...params.businessIds].sort().join(',') : 'none'; + return `${params.userId}|${scope}|${params.toolName}`; +} + +export class RateLimiter implements RateLimiterLike { + private readonly buckets = new Map(); + private readonly windowMs: number; + private readonly max: number; + private readonly now: () => number; + private checksSinceSweep = 0; + + constructor(config: RateLimitConfig = DEFAULT_RATE_LIMIT, now: () => number = Date.now) { + this.windowMs = config.windowMs; + this.max = config.max; + this.now = now; + } + + /** + * Record a request against `key` and report whether it is allowed. A denied + * request does not consume additional budget. + */ + check(key: string): RateLimitResult { + const now = this.now(); + this.maybeSweep(now); + let bucket = this.buckets.get(key); + if (!bucket || now >= bucket.resetAt) { + bucket = { count: 0, resetAt: now + this.windowMs }; + this.buckets.set(key, bucket); + } + + if (bucket.count >= this.max) { + return { + allowed: false, + limit: this.max, + remaining: 0, + retryAfterMs: Math.max(0, bucket.resetAt - now), + }; + } + + bucket.count += 1; + return { + allowed: true, + limit: this.max, + remaining: this.max - bucket.count, + retryAfterMs: Math.max(0, bucket.resetAt - now), + }; + } + + /** + * Periodically drop buckets whose window has already elapsed so the map does + * not grow without bound in a long-lived process with many distinct keys. + * Runs at most once per {@link SWEEP_EVERY} checks to keep `check` O(1) + * amortized. + */ + private maybeSweep(now: number): void { + this.checksSinceSweep += 1; + if (this.checksSinceSweep < SWEEP_EVERY) { + return; + } + this.checksSinceSweep = 0; + for (const [key, bucket] of this.buckets) { + if (now >= bucket.resetAt) { + this.buckets.delete(key); + } + } + } + + /** Test/ops helper: drop all counters. */ + reset(): void { + this.buckets.clear(); + this.checksSinceSweep = 0; + } +} + +/** + * Parse the `MCP_RATE_LIMIT_CONFIG` value (JSON `{ "windowMs": …, "max": … }`). + * Falls back to {@link DEFAULT_RATE_LIMIT} on empty/invalid input. + */ +export function parseRateLimitConfig(raw: string | undefined): RateLimitConfig { + if (!raw || raw.trim() === '') { + return DEFAULT_RATE_LIMIT; + } + try { + const parsed = JSON.parse(raw) as { windowMs?: unknown; max?: unknown }; + const windowMs = + typeof parsed.windowMs === 'number' && Number.isFinite(parsed.windowMs) && parsed.windowMs > 0 + ? parsed.windowMs + : DEFAULT_RATE_LIMIT.windowMs; + // Floor before the positivity check so a fractional value in (0, 1) — e.g. + // `{ "max": 0.5 }` — falls back to the default instead of becoming `max: 0`, + // which would rate-limit every request. + const flooredMax = + typeof parsed.max === 'number' && Number.isFinite(parsed.max) + ? Math.floor(parsed.max) + : Number.NaN; + const max = flooredMax >= 1 ? flooredMax : DEFAULT_RATE_LIMIT.max; + return { windowMs, max }; + } catch { + return DEFAULT_RATE_LIMIT; + } +} diff --git a/packages/mcp-server/src/tools/__tests__/rate-limit.execute.test.ts b/packages/mcp-server/src/tools/__tests__/rate-limit.execute.test.ts new file mode 100644 index 000000000..72cfccc93 --- /dev/null +++ b/packages/mcp-server/src/tools/__tests__/rate-limit.execute.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from 'vitest'; +import { buildAuthContext, type McpAuthContext } from '../../auth/identity.js'; +import type { AuthPrincipal } from '../../auth/token.js'; +import { RateLimiter } from '../../rate-limit/limiter.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 client() { + const fetchImpl = vi.fn( + async () => + ({ + ok: true, + status: 200, + json: async () => ({ + data: { allCharges: { nodes: [], pageInfo: { totalPages: 0, totalRecords: 0, currentPage: 1, pageSize: 25 } } }, + }), + }) as unknown as Response, + ); + return new UpstreamGraphQLClient({ + endpoint: 'http://localhost:4000/graphql', + timeoutMs: 1000, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); +} + +function run(limiter: RateLimiter, auth: McpAuthContext) { + return executeRegisteredTool({ + tool: searchChargesTool, + rawArgs: {}, + auth, + correlationId: 'corr-1', + client: client(), + authorization: 'Bearer tok', + limiter, + }); +} + +describe('executeRegisteredTool — rate limiting', () => { + it('allows within the limit, then returns RATE_LIMIT_ERROR', async () => { + const limiter = new RateLimiter({ windowMs: 1000, max: 2 }, () => 0); + const auth = authContext(['b1']); + + expect((await run(limiter, auth)).isError).toBeUndefined(); + expect((await run(limiter, auth)).isError).toBeUndefined(); + + const limited = await run(limiter, auth); + expect(limited.isError).toBe(true); + const structured = limited.structuredContent as { code: string; retryable: boolean; retryAfterMs: number }; + expect(structured.code).toBe('RATE_LIMIT_ERROR'); + expect(structured.retryable).toBe(true); + expect(structured.retryAfterMs).toBe(1000); + }); + + it('keys the limit by tool + business scope (different scope is independent)', async () => { + const limiter = new RateLimiter({ windowMs: 1000, max: 1 }, () => 0); + expect((await run(limiter, authContext(['b1']))).isError).toBeUndefined(); + // Same user + tool but a different business scope → separate bucket. + expect((await run(limiter, authContext(['b2']))).isError).toBeUndefined(); + // Repeat of the first scope is now limited. + expect((await run(limiter, authContext(['b1']))).isError).toBe(true); + }); +}); diff --git a/packages/mcp-server/src/tools/execute.ts b/packages/mcp-server/src/tools/execute.ts index 292f0e022..884de6c83 100644 --- a/packages/mcp-server/src/tools/execute.ts +++ b/packages/mcp-server/src/tools/execute.ts @@ -6,6 +6,7 @@ import { toToolErrorResult, } from '../errors/taxonomy.js'; import { log } from '../logger.js'; +import { rateLimitKey, type RateLimiterLike } from '../rate-limit/limiter.js'; import type { UpstreamGraphQLClient } from '../upstream/graphql-client.js'; import { evaluateToolPolicy } from './policy.js'; import { @@ -53,6 +54,8 @@ export interface ExecuteToolParams { correlationId: string; client: UpstreamGraphQLClient; authorization?: string; + /** Optional rate limiter; when provided, enforced before the handler runs. */ + limiter?: RateLimiterLike; } /** @@ -60,7 +63,7 @@ export interface ExecuteToolParams { * {@link ToolResult} — success or a taxonomy-tagged error result. */ export async function executeRegisteredTool(params: ExecuteToolParams): Promise { - const { tool, rawArgs, auth, correlationId, client, authorization } = params; + const { tool, rawArgs, auth, correlationId, client, authorization, limiter } = params; // 1. Strict input validation (unknown fields rejected). const validation = validateToolInput(tool, rawArgs); @@ -85,7 +88,30 @@ export async function executeRegisteredTool(params: ExecuteToolParams): Promise< ); } - // 3. Execute the handler. + // 3. Rate limit (before any expensive upstream call), keyed by + // identity + business scope + tool. + if (limiter) { + const outcome = limiter.check( + rateLimitKey({ + userId: auth.userId, + toolName: tool.name, + businessIds: decision.readScope.businessIds, + }), + ); + if (!outcome.allowed) { + const retryAfterSeconds = Math.ceil(outcome.retryAfterMs / 1000); + return toToolErrorResult( + errorPayload( + 'RATE_LIMIT_ERROR', + `Rate limit exceeded. Retry in ${retryAfterSeconds}s.`, + correlationId, + { retryAfterMs: outcome.retryAfterMs }, + ), + ); + } + } + + // 4. Execute the handler. const context: ToolExecutionContext = { auth, readScope: decision.readScope, From 45592ec143d7212bc7a49c45d7dae557f12496a3 Mon Sep 17 00:00:00 2001 From: Gil Gardosh Date: Thu, 30 Jul 2026 10:20:12 +0300 Subject: [PATCH 31/38] feat(mcp-server): metrics and tracing telemetry (prompt 19) (#4023) * feat(mcp-server): add metrics and tracing telemetry Add operational telemetry for production readiness (blueprint prompt 19): - Metrics registry (src/observability/metrics.ts): request counters keyed by tool/outcome, a latency histogram, auth-failure counters by reason, upstream error counters, and a rate-limited counter. Labels carry no PII. - Tracing spans (src/observability/tracing.ts): lightweight withSpan wrapper emitting structured start/end debug logs with the correlation id and duration; wired around token verification (auth:verify), tool execution (tool:), and each upstream GraphQL call (upstream:graphql). - Record metrics from the tool executor (success/error outcome + latency) and auth-failure counters from the transport handler. - Expose a snapshot at GET /metrics. - Unit tests for the metrics registry, outcomeForCode mapping, withSpan start/end/error logging, and executor metric recording. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo * fix(mcp-server): address metrics review feedback - Reconcile the latency-histogram docs with its behavior: buckets hold per-bucket (non-cumulative) counts plus an +Inf overflow bucket; update the code comment and README to match instead of describing it as cumulative. - Guard outcomeOf against a non-taxonomy error code: fall back to internal_error rather than recording a `|undefined` metric key (folded into the metrics commit during rebase). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo * prettier --------- Co-authored-by: Claude --- packages/mcp-server/README.md | 39 ++++- packages/mcp-server/src/mcp/handler.ts | 8 +- .../observability/__tests__/metrics.test.ts | 121 ++++++++++++++ .../observability/__tests__/tracing.test.ts | 51 ++++++ .../mcp-server/src/observability/metrics.ts | 155 ++++++++++++++++++ .../mcp-server/src/observability/tracing.ts | 37 +++++ packages/mcp-server/src/server.ts | 5 + .../tools/__tests__/metrics.execute.test.ts | 111 +++++++++++++ packages/mcp-server/src/tools/execute.ts | 56 ++++++- .../mcp-server/src/upstream/graphql-client.ts | 28 ++-- 10 files changed, 593 insertions(+), 18 deletions(-) create mode 100644 packages/mcp-server/src/observability/__tests__/metrics.test.ts create mode 100644 packages/mcp-server/src/observability/__tests__/tracing.test.ts create mode 100644 packages/mcp-server/src/observability/metrics.ts create mode 100644 packages/mcp-server/src/observability/tracing.ts create mode 100644 packages/mcp-server/src/tools/__tests__/metrics.execute.test.ts diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 851c3be96..47c997c9e 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -20,7 +20,9 @@ Auth0 bearer-token verification, identity mapping from a verified token to an in business-membership context, a curated tool registry with strict input validation, a per-tool 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. +authorization policy, and business-scope narrowing before execution. Operational telemetry +(request/outcome counters, a latency histogram, auth-failure counters, and tracing spans) is +recorded per process and exposed at `GET /metrics`. ## Tools @@ -95,9 +97,38 @@ signals that tokens are accepted only via the `Authorization` header, never quer Every request is assigned a `requestId` and a `correlationId` (the latter inherited from an inbound `X-Correlation-Id` header when present, otherwise generated). The correlation id is echoed back on -the response. Structured JSON logs are emitted at request start and completion, carrying -`requestId`, `correlationId`, `method`, `route`, and — on completion — `status` and `latencyMs`. -Secrets and authorization headers are never logged. +the response and propagated upstream via the `X-Correlation-Id` header on every GraphQL call. +Structured JSON logs are emitted at request start and completion, carrying `requestId`, +`correlationId`, `method`, `route`, and — on completion — `status` and `latencyMs`. Secrets and +authorization headers are never logged. + +### Metrics + +An in-memory metrics registry (`src/observability/metrics.ts`) records operational telemetry per +process (labels never carry PII — only tool names, outcome classes, and error categories): + +- **`requestsTotal`** — request counter keyed by `"|"`, where outcome is `success` or + one of the taxonomy-derived error classes (`validation_error`, `authentication_error`, + `authorization_error`, `rate_limited`, `upstream_error`, `timeout_error`, `internal_error`). +- **`latencyMs`** — a latency histogram with per-bucket (non-cumulative) counts in ms plus an `+Inf` + overflow bucket, alongside running `count`/`sum` totals. +- **`authFailuresTotal`** — auth failure counter keyed by reason (`missing_token`, `invalid_token`). +- **`upstreamErrorsTotal`** — upstream failure counter keyed by category. +- **`rateLimitedTotal`** — total rate-limited requests. + +A snapshot is exposed at `GET /metrics`: + +```bash +curl http://localhost:3100/metrics +``` + +### Tracing spans + +Lightweight tracing spans (`src/observability/tracing.ts`) wrap the key units of work — token +verification (`auth:verify`), tool execution (`tool:`), and each upstream GraphQL call +(`upstream:graphql`) — emitting structured `span start`/`span end` debug logs carrying the +correlation id and span duration. The implementation is deliberately dependency-free so it can be +swapped for OpenTelemetry later without touching call sites. ## Running locally diff --git a/packages/mcp-server/src/mcp/handler.ts b/packages/mcp-server/src/mcp/handler.ts index 0641b3b38..d8f0660cd 100644 --- a/packages/mcp-server/src/mcp/handler.ts +++ b/packages/mcp-server/src/mcp/handler.ts @@ -18,6 +18,8 @@ import { generateId, getRequestContext } from '../context.js'; import { createRequestLogger, log } from '../logger.js'; import { sendUnauthorized } from '../oauth/challenge.js'; import { protectedResourceMetadataUrl } from '../oauth/metadata.js'; +import { getMetrics } from '../observability/metrics.js'; +import { withSpan } from '../observability/tracing.js'; import { getRateLimiter } from '../rate-limit/default-limiter.js'; import { executeRegisteredTool } from '../tools/execute.js'; import { toolRegistry } from '../tools/registry-instance.js'; @@ -191,6 +193,7 @@ export async function dispatchMcpRequest( authorization: context.authorization, client: getUpstreamClient(), limiter: getRateLimiter(), + metrics: getMetrics(), }); return success(id, result); } @@ -254,8 +257,10 @@ async function authenticate( req: IncomingMessage, res: ServerResponse, ): Promise { + const correlationId = getRequestContext(req)?.correlationId ?? ''; const token = extractBearerToken(req); if (!token) { + getMetrics().recordAuthFailure('missing_token'); sendUnauthorized(res, { resourceMetadataUrl: protectedResourceMetadataUrl(env.server.publicBaseUrl), }); @@ -263,7 +268,7 @@ async function authenticate( } try { - const principal = await verifyAccessToken(token); + const principal = await withSpan('auth:verify', correlationId, () => verifyAccessToken(token)); setAuthPrincipal(req, principal); // Map the verified identity to internal user + business membership context. // Memberships are resolved from the Accounter server (not token claims) by @@ -285,6 +290,7 @@ async function authenticate( if (!(error instanceof TokenVerificationError) && !(error instanceof IdentityMappingError)) { throw error; } + getMetrics().recordAuthFailure('invalid_token'); // Log the reason only — never the token. const context = getRequestContext(req); if (context) { diff --git a/packages/mcp-server/src/observability/__tests__/metrics.test.ts b/packages/mcp-server/src/observability/__tests__/metrics.test.ts new file mode 100644 index 000000000..0e95af5d1 --- /dev/null +++ b/packages/mcp-server/src/observability/__tests__/metrics.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest'; +import type { McpErrorCode } from '../../errors/taxonomy.js'; +import { + getMetrics, + LATENCY_BUCKETS_MS, + Metrics, + outcomeForCode, + resetMetrics, + type RequestOutcome, +} from '../metrics.js'; + +describe('outcomeForCode', () => { + const cases: Array<[McpErrorCode, RequestOutcome]> = [ + ['VALIDATION_ERROR', 'validation_error'], + ['AUTHENTICATION_ERROR', 'authentication_error'], + ['AUTHORIZATION_ERROR', 'authorization_error'], + ['RATE_LIMIT_ERROR', 'rate_limited'], + ['UPSTREAM_ERROR', 'upstream_error'], + ['TIMEOUT_ERROR', 'timeout_error'], + ['INTERNAL_ERROR', 'internal_error'], + ]; + + it.each(cases)('maps %s to %s', (code, outcome) => { + expect(outcomeForCode(code)).toBe(outcome); + }); +}); + +describe('Metrics', () => { + it('counts requests keyed by tool + outcome', () => { + const metrics = new Metrics(); + metrics.recordRequest('search_charges', 'success', 12); + metrics.recordRequest('search_charges', 'success', 20); + metrics.recordRequest('search_charges', 'validation_error', 3); + + const snapshot = metrics.snapshot(); + expect(snapshot.requestsTotal).toEqual({ + 'search_charges|success': 2, + 'search_charges|validation_error': 1, + }); + }); + + it('accumulates latency count and sum', () => { + const metrics = new Metrics(); + metrics.observeLatency(10); + metrics.observeLatency(30); + + const { latencyMs } = metrics.snapshot(); + expect(latencyMs.count).toBe(2); + expect(latencyMs.sum).toBe(40); + }); + + it('places latencies into the correct histogram buckets', () => { + const metrics = new Metrics(); + metrics.observeLatency(3); // <= 5 + metrics.observeLatency(5); // <= 5 (boundary is inclusive) + metrics.observeLatency(7); // <= 10 + metrics.observeLatency(9999); // +Inf + + const { buckets } = metrics.snapshot().latencyMs; + expect(buckets['5']).toBe(2); + expect(buckets['10']).toBe(1); + expect(buckets['+Inf']).toBe(1); + }); + + it('exposes a bucket label for every configured bound plus +Inf', () => { + const metrics = new Metrics(); + const { buckets } = metrics.snapshot().latencyMs; + for (const bound of LATENCY_BUCKETS_MS) { + expect(buckets).toHaveProperty(String(bound)); + } + expect(buckets).toHaveProperty('+Inf'); + }); + + it('counts auth failures, upstream errors, and rate limits', () => { + const metrics = new Metrics(); + metrics.recordAuthFailure('missing_token'); + metrics.recordAuthFailure('invalid_token'); + metrics.recordAuthFailure('invalid_token'); + metrics.recordUpstreamError('upstream_error'); + metrics.recordRateLimited(); + metrics.recordRateLimited(); + + const snapshot = metrics.snapshot(); + expect(snapshot.authFailuresTotal).toEqual({ missing_token: 1, invalid_token: 2 }); + expect(snapshot.upstreamErrorsTotal).toEqual({ upstream_error: 1 }); + expect(snapshot.rateLimitedTotal).toBe(2); + }); + + it('reset() clears all counters and the histogram', () => { + const metrics = new Metrics(); + metrics.recordRequest('t', 'success', 10); + metrics.recordAuthFailure('missing_token'); + metrics.recordUpstreamError('upstream_error'); + metrics.recordRateLimited(); + + metrics.reset(); + + const snapshot = metrics.snapshot(); + expect(snapshot.requestsTotal).toEqual({}); + expect(snapshot.authFailuresTotal).toEqual({}); + expect(snapshot.upstreamErrorsTotal).toEqual({}); + expect(snapshot.rateLimitedTotal).toBe(0); + expect(snapshot.latencyMs.count).toBe(0); + expect(snapshot.latencyMs.sum).toBe(0); + expect(snapshot.latencyMs.buckets['+Inf']).toBe(0); + }); +}); + +describe('getMetrics / resetMetrics', () => { + it('returns a memoized process-wide singleton', () => { + expect(getMetrics()).toBe(getMetrics()); + }); + + it('resetMetrics clears the singleton without replacing it', () => { + const before = getMetrics(); + before.recordRateLimited(); + resetMetrics(); + expect(getMetrics()).toBe(before); + expect(getMetrics().snapshot().rateLimitedTotal).toBe(0); + }); +}); diff --git a/packages/mcp-server/src/observability/__tests__/tracing.test.ts b/packages/mcp-server/src/observability/__tests__/tracing.test.ts new file mode 100644 index 000000000..513155a54 --- /dev/null +++ b/packages/mcp-server/src/observability/__tests__/tracing.test.ts @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as logger from '../../logger.js'; +import { withSpan } from '../tracing.js'; + +function spyLog() { + return vi.spyOn(logger, 'log').mockImplementation(() => {}); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('withSpan', () => { + it('returns the wrapped function result', async () => { + spyLog(); + await expect(withSpan('work', 'corr-1', async () => 42)).resolves.toBe(42); + }); + + it('emits a start and a successful end span carrying the correlation id', async () => { + const log = spyLog(); + await withSpan('tool:search', 'corr-1', async () => 'ok'); + + expect(log).toHaveBeenCalledTimes(2); + expect(log).toHaveBeenNthCalledWith(1, 'debug', 'span start', { + span: 'tool:search', + correlationId: 'corr-1', + }); + const [, , endFields] = log.mock.calls[1]; + expect(endFields).toMatchObject({ span: 'tool:search', correlationId: 'corr-1', ok: true }); + expect(typeof (endFields as { durationMs: number }).durationMs).toBe('number'); + }); + + it('logs a failed end span and re-throws the error', async () => { + const log = spyLog(); + const boom = new TypeError('nope'); + + await expect( + withSpan('auth:verify', 'corr-2', async () => { + throw boom; + }), + ).rejects.toBe(boom); + + const [, , endFields] = log.mock.calls[1]; + expect(endFields).toMatchObject({ + span: 'auth:verify', + correlationId: 'corr-2', + ok: false, + error: 'TypeError', + }); + }); +}); diff --git a/packages/mcp-server/src/observability/metrics.ts b/packages/mcp-server/src/observability/metrics.ts new file mode 100644 index 000000000..2ceca7249 --- /dev/null +++ b/packages/mcp-server/src/observability/metrics.ts @@ -0,0 +1,155 @@ +import type { McpErrorCode } from '../errors/taxonomy.js'; + +/** + * In-memory operational metrics (spec §11.2). + * + * Counters and a latency histogram, aggregated per process. `snapshot()` + * renders a plain object for a `/metrics` endpoint or tests. Labels never carry + * PII — only tool names, outcome classes, and error categories. + */ + +/** Outcome class recorded per tool request. */ +export type RequestOutcome = + | 'success' + | 'validation_error' + | 'authentication_error' + | 'authorization_error' + | 'rate_limited' + | 'upstream_error' + | 'timeout_error' + | 'internal_error'; + +/** Map an error taxonomy code to its request-outcome label. */ +export function outcomeForCode(code: McpErrorCode): RequestOutcome { + switch (code) { + case 'VALIDATION_ERROR': + return 'validation_error'; + case 'AUTHENTICATION_ERROR': + return 'authentication_error'; + case 'AUTHORIZATION_ERROR': + return 'authorization_error'; + case 'RATE_LIMIT_ERROR': + return 'rate_limited'; + case 'UPSTREAM_ERROR': + return 'upstream_error'; + case 'TIMEOUT_ERROR': + return 'timeout_error'; + case 'INTERNAL_ERROR': + return 'internal_error'; + } +} + +/** Upper bounds (ms) for the latency histogram buckets. */ +export const LATENCY_BUCKETS_MS = [5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000] as const; + +interface Histogram { + /** + * Per-bucket counts (non-cumulative): each entry counts observations that + * fell into that single bucket, plus a final `+Inf` overflow bucket. The + * running total is tracked separately in `count`. + */ + buckets: number[]; + count: number; + sum: number; +} + +export interface MetricsSnapshot { + /** `mcp_requests_total` keyed by `"|"`. */ + requestsTotal: Record; + /** `auth_failures_total` keyed by reason. */ + authFailuresTotal: Record; + /** `upstream_graphql_errors_total` keyed by category. */ + upstreamErrorsTotal: Record; + /** `rate_limited_total`. */ + rateLimitedTotal: number; + /** `mcp_request_latency_ms` histogram. */ + latencyMs: { count: number; sum: number; buckets: Record }; +} + +function inc(map: Map, key: string): void { + map.set(key, (map.get(key) ?? 0) + 1); +} + +export class Metrics { + private readonly requests = new Map(); + private readonly authFailures = new Map(); + private readonly upstreamErrors = new Map(); + private rateLimited = 0; + private readonly latency: Histogram = { + buckets: new Array(LATENCY_BUCKETS_MS.length + 1).fill(0), + count: 0, + sum: 0, + }; + + /** Record a finished tool request with its outcome and latency. */ + recordRequest(tool: string, outcome: RequestOutcome, latencyMs: number): void { + inc(this.requests, `${tool}|${outcome}`); + this.observeLatency(latencyMs); + } + + observeLatency(ms: number): void { + this.latency.count += 1; + this.latency.sum += ms; + let placed = false; + for (let i = 0; i < LATENCY_BUCKETS_MS.length; i += 1) { + if (ms <= LATENCY_BUCKETS_MS[i]) { + this.latency.buckets[i] += 1; + placed = true; + break; + } + } + if (!placed) { + this.latency.buckets[LATENCY_BUCKETS_MS.length] += 1; // +Inf + } + } + + recordAuthFailure(reason: string): void { + inc(this.authFailures, reason); + } + + recordUpstreamError(category: string): void { + inc(this.upstreamErrors, category); + } + + recordRateLimited(): void { + this.rateLimited += 1; + } + + snapshot(): MetricsSnapshot { + const bucketLabels: Record = {}; + for (const [i, bound] of LATENCY_BUCKETS_MS.entries()) { + bucketLabels[String(bound)] = this.latency.buckets[i]; + } + bucketLabels['+Inf'] = this.latency.buckets[LATENCY_BUCKETS_MS.length]; + return { + requestsTotal: Object.fromEntries(this.requests), + authFailuresTotal: Object.fromEntries(this.authFailures), + upstreamErrorsTotal: Object.fromEntries(this.upstreamErrors), + rateLimitedTotal: this.rateLimited, + latencyMs: { count: this.latency.count, sum: this.latency.sum, buckets: bucketLabels }, + }; + } + + reset(): void { + this.requests.clear(); + this.authFailures.clear(); + this.upstreamErrors.clear(); + this.rateLimited = 0; + this.latency.buckets.fill(0); + this.latency.count = 0; + this.latency.sum = 0; + } +} + +let processMetrics: Metrics | undefined; + +/** The process-wide metrics registry (no env dependency). */ +export function getMetrics(): Metrics { + processMetrics ??= new Metrics(); + return processMetrics; +} + +/** Test-only: reset the process metrics registry. */ +export function resetMetrics(): void { + getMetrics().reset(); +} diff --git a/packages/mcp-server/src/observability/tracing.ts b/packages/mcp-server/src/observability/tracing.ts new file mode 100644 index 000000000..0e67e699b --- /dev/null +++ b/packages/mcp-server/src/observability/tracing.ts @@ -0,0 +1,37 @@ +import { log } from '../logger.js'; + +/** + * Minimal tracing spans (spec §11.3). + * + * Wraps a unit of work (auth validation, an upstream call, tool execution) and + * emits structured start/end debug logs carrying the correlation id and the + * span duration. Deliberately lightweight — no external tracer dependency — so + * it can be swapped for OpenTelemetry later without touching call sites. + */ +export async function withSpan( + name: string, + correlationId: string, + fn: () => Promise, +): Promise { + const start = performance.now(); + log('debug', 'span start', { span: name, correlationId }); + try { + const result = await fn(); + log('debug', 'span end', { + span: name, + correlationId, + durationMs: Math.round(performance.now() - start), + ok: true, + }); + return result; + } catch (error) { + log('debug', 'span end', { + span: name, + correlationId, + durationMs: Math.round(performance.now() - start), + ok: false, + error: error instanceof Error ? error.name : 'unknown', + }); + throw error; + } +} diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 43cc04b67..86001609c 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -7,6 +7,7 @@ import { buildProtectedResourceMetadata, PROTECTED_RESOURCE_METADATA_PATH, } from './oauth/metadata.js'; +import { getMetrics } from './observability/metrics.js'; import { getServiceVersion, SERVICE_NAME } from './version.js'; /** @@ -46,6 +47,10 @@ export const routes: Record> = { }; sendJson(res, 200, body); }, + // Operational metrics snapshot (counters + latency histogram). + '/metrics': (_req, res) => { + sendJson(res, 200, getMetrics().snapshot()); + }, // OAuth 2.0 Protected Resource Metadata (RFC 9728) — lets Claude clients // discover the Auth0 authorization server for this resource. [PROTECTED_RESOURCE_METADATA_PATH]: (_req, res) => { diff --git a/packages/mcp-server/src/tools/__tests__/metrics.execute.test.ts b/packages/mcp-server/src/tools/__tests__/metrics.execute.test.ts new file mode 100644 index 000000000..74a40b2d6 --- /dev/null +++ b/packages/mcp-server/src/tools/__tests__/metrics.execute.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it, vi } from 'vitest'; +import { buildAuthContext, type McpAuthContext } from '../../auth/identity.js'; +import type { AuthPrincipal } from '../../auth/token.js'; +import { Metrics } from '../../observability/metrics.js'; +import { RateLimiter } from '../../rate-limit/limiter.js'; +import { UpstreamGraphQLClient } from '../../upstream/graphql-client.js'; +import { SEARCH_CHARGES_TOOL_NAME, searchChargesTool } from '../charges.js'; +import { executeRegisteredTool } from '../execute.js'; + +function authContext(businessIds: string[] = ['b1']): 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 client() { + const fetchImpl = vi.fn( + async () => + ({ + ok: true, + status: 200, + json: async () => ({ + data: { + allCharges: { + nodes: [], + pageInfo: { totalPages: 0, totalRecords: 0, currentPage: 1, pageSize: 25 }, + }, + }, + }), + }) as unknown as Response, + ); + return new UpstreamGraphQLClient({ + endpoint: 'http://localhost:4000/graphql', + timeoutMs: 1000, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); +} + +describe('executeRegisteredTool — metrics', () => { + it('records a success outcome and one latency observation', async () => { + const metrics = new Metrics(); + const result = await executeRegisteredTool({ + tool: searchChargesTool, + rawArgs: {}, + auth: authContext(), + correlationId: 'corr-1', + client: client(), + metrics, + }); + + expect(result.isError).toBeUndefined(); + const snapshot = metrics.snapshot(); + expect(snapshot.requestsTotal[`${SEARCH_CHARGES_TOOL_NAME}|success`]).toBe(1); + expect(snapshot.latencyMs.count).toBe(1); + }); + + it('records a validation_error outcome for bad input', async () => { + const metrics = new Metrics(); + const result = await executeRegisteredTool({ + tool: searchChargesTool, + rawArgs: { unknownField: true }, + auth: authContext(), + correlationId: 'corr-1', + client: client(), + metrics, + }); + + expect(result.isError).toBe(true); + expect(metrics.snapshot().requestsTotal[`${SEARCH_CHARGES_TOOL_NAME}|validation_error`]).toBe(1); + }); + + it('records a rate_limited outcome and bumps the rate-limited counter', async () => { + const metrics = new Metrics(); + const limiter = new RateLimiter({ windowMs: 1000, max: 0 }, () => 0); + const result = await executeRegisteredTool({ + tool: searchChargesTool, + rawArgs: {}, + auth: authContext(), + correlationId: 'corr-1', + client: client(), + limiter, + metrics, + }); + + expect(result.isError).toBe(true); + const snapshot = metrics.snapshot(); + expect(snapshot.requestsTotal[`${SEARCH_CHARGES_TOOL_NAME}|rate_limited`]).toBe(1); + expect(snapshot.rateLimitedTotal).toBe(1); + }); + + it('is a no-op when no metrics registry is provided', async () => { + const result = await executeRegisteredTool({ + tool: searchChargesTool, + rawArgs: {}, + auth: authContext(), + correlationId: 'corr-1', + client: client(), + }); + expect(result.isError).toBeUndefined(); + }); +}); diff --git a/packages/mcp-server/src/tools/execute.ts b/packages/mcp-server/src/tools/execute.ts index 884de6c83..a723a4259 100644 --- a/packages/mcp-server/src/tools/execute.ts +++ b/packages/mcp-server/src/tools/execute.ts @@ -4,8 +4,11 @@ import { isInternalError, toErrorPayload, toToolErrorResult, + type McpErrorCode, } from '../errors/taxonomy.js'; import { log } from '../logger.js'; +import { outcomeForCode, type Metrics, type RequestOutcome } from '../observability/metrics.js'; +import { withSpan } from '../observability/tracing.js'; import { rateLimitKey, type RateLimiterLike } from '../rate-limit/limiter.js'; import type { UpstreamGraphQLClient } from '../upstream/graphql-client.js'; import { evaluateToolPolicy } from './policy.js'; @@ -56,13 +59,59 @@ export interface ExecuteToolParams { authorization?: string; /** Optional rate limiter; when provided, enforced before the handler runs. */ limiter?: RateLimiterLike; + /** Optional metrics registry; when provided, records outcome + latency. */ + metrics?: Metrics; +} + +/** The error taxonomy codes that map to a known request outcome. */ +const KNOWN_ERROR_CODES = new Set([ + 'VALIDATION_ERROR', + 'AUTHENTICATION_ERROR', + 'AUTHORIZATION_ERROR', + 'UPSTREAM_ERROR', + 'TIMEOUT_ERROR', + 'RATE_LIMIT_ERROR', + 'INTERNAL_ERROR', +]); + +/** Derive the metrics outcome label from a finished tool result. */ +function outcomeOf(result: ToolResult): RequestOutcome { + if (!result.isError) { + return 'success'; + } + const code = (result.structuredContent as { code?: string } | undefined)?.code; + // Guard against a handler returning `isError` with a non-taxonomy code: fall + // back to `internal_error` rather than recording a `|undefined` key. + return code && KNOWN_ERROR_CODES.has(code) + ? outcomeForCode(code as McpErrorCode) + : 'internal_error'; } /** * Validate, authorize, and execute a registered tool. Always resolves to a - * {@link ToolResult} — success or a taxonomy-tagged error result. + * {@link ToolResult} — success or a taxonomy-tagged error result. Records + * request outcome, latency, and error-category metrics when a registry is + * provided. */ export async function executeRegisteredTool(params: ExecuteToolParams): Promise { + const { metrics } = params; + const start = performance.now(); + const result = await runTool(params); + + if (metrics) { + const outcome = outcomeOf(result); + metrics.recordRequest(params.tool.name, outcome, Math.round(performance.now() - start)); + if (outcome === 'rate_limited') { + metrics.recordRateLimited(); + } else if (outcome === 'upstream_error' || outcome === 'timeout_error') { + metrics.recordUpstreamError(outcome); + } + } + + return result; +} + +async function runTool(params: ExecuteToolParams): Promise { const { tool, rawArgs, auth, correlationId, client, authorization, limiter } = params; // 1. Strict input validation (unknown fields rejected). @@ -120,7 +169,10 @@ export async function executeRegisteredTool(params: ExecuteToolParams): Promise< authorization, }; try { - return await tool.handler(input, context); + // Span covers the handler + its upstream calls for tracing. + return await withSpan(`tool:${tool.name}`, correlationId, async () => + tool.handler(input, context), + ); } catch (error) { // Log unexpected (unmapped) failures so bugs aren't hidden; the caller only // ever sees the generic, sanitized INTERNAL_ERROR message. diff --git a/packages/mcp-server/src/upstream/graphql-client.ts b/packages/mcp-server/src/upstream/graphql-client.ts index 66b6ec644..71049be18 100644 --- a/packages/mcp-server/src/upstream/graphql-client.ts +++ b/packages/mcp-server/src/upstream/graphql-client.ts @@ -13,6 +13,8 @@ * - Upstream errors are sanitized (no stack traces / internal SQL details). */ +import { withSpan } from '../observability/tracing.js'; + export type UpstreamErrorCode = 'UPSTREAM_ERROR' | 'TIMEOUT_ERROR'; /** @@ -118,19 +120,23 @@ export class UpstreamGraphQLClient { async query(request: GraphQLRequest, context: UpstreamRequestContext): Promise { assertReadOnly(request.query); - let attempt = 0; - // Total tries = 1 + maxRetries; only retryable failures loop. - for (;;) { - try { - return await this.executeOnce(request, context); - } catch (error) { - const isRetryable = error instanceof UpstreamError && error.retryable; - if (!isRetryable || attempt >= this.maxRetries) { - throw error; + // Span covers all retry attempts; the correlation id also propagates to the + // upstream server via the X-Correlation-Id header on each attempt. + return withSpan('upstream:graphql', context.correlationId, async () => { + let attempt = 0; + // Total tries = 1 + maxRetries; only retryable failures loop. + for (;;) { + try { + return await this.executeOnce(request, context); + } catch (error) { + const isRetryable = error instanceof UpstreamError && error.retryable; + if (!isRetryable || attempt >= this.maxRetries) { + throw error; + } + attempt += 1; } - attempt += 1; } - } + }); } private async executeOnce( From 42a1202882cfcd22afa762701054627cf619f82b Mon Sep 17 00:00:00 2001 From: Gil Gardosh Date: Thu, 30 Jul 2026 10:41:21 +0300 Subject: [PATCH 32/38] test(mcp-server): complete the unit test matrix (#4024) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round out deterministic, service-free unit coverage across the core modules (blueprint prompt 20): - Env config: cover loadEnv's fail-fast startup path (invalid → report + exit) and the valid-source happy path, with process.exit/console.error stubbed. - Token validation edge cases: verify via a key-resolver function, reject a signature-valid token missing the subject claim, and round-trip the per-request principal store (setAuthPrincipal/getAuthPrincipal isolation). - Charges tool: reject a single impossible toDate and an over-cap date range. - Balance report tool: reject an impossible (regex-passing) date. - Output truncation: default-summary branches ("No results.", returned-of-total with and without the truncated marker). Branch coverage improves from ~83.9% to ~85.9%; token.ts reaches full line coverage. Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo Co-authored-by: Claude --- .../src/auth/__tests__/token.test.ts | 53 +++++++++++++++++++ .../src/config/__tests__/env.test.ts | 45 +++++++++++++++- .../src/tools/__tests__/charges.test.ts | 17 ++++++ .../src/tools/__tests__/output.test.ts | 20 +++++++ .../src/tools/__tests__/reports.test.ts | 15 ++++++ 5 files changed, 148 insertions(+), 2 deletions(-) diff --git a/packages/mcp-server/src/auth/__tests__/token.test.ts b/packages/mcp-server/src/auth/__tests__/token.test.ts index 69cf9b809..c7387fea0 100644 --- a/packages/mcp-server/src/auth/__tests__/token.test.ts +++ b/packages/mcp-server/src/auth/__tests__/token.test.ts @@ -3,9 +3,12 @@ import { generateKeyPair, type JWTVerifyGetKey, type KeyObject, SignJWT } from ' import { beforeAll, describe, expect, it } from 'vitest'; import { extractBearerToken, + getAuthPrincipal, + setAuthPrincipal, toPrincipal, TokenVerificationError, verifyAccessTokenWithKey, + type AuthPrincipal, } from '../token.js'; const ISSUER = 'https://tenant.auth0.com/'; @@ -132,4 +135,54 @@ describe('verifyAccessTokenWithKey', () => { verifyAccessTokenWithKey(token, getKey, { issuer: ISSUER, audience: AUDIENCE }), ).rejects.toBe(infra); }); + + it('accepts a valid token verified through a key-resolver function', async () => { + const token = await sign({}); + const getKey: JWTVerifyGetKey = async () => publicKey; + const principal = await verifyAccessTokenWithKey(token, getKey, { + issuer: ISSUER, + audience: AUDIENCE, + }); + expect(principal.subject).toBe('user-1'); + }); + + it('rejects a signature-valid token that is missing the subject claim', async () => { + // Signed correctly (so jwtVerify passes) but carries no `sub` — toPrincipal + // must reject it, and the error surfaces as a TokenVerificationError. + const token = await new SignJWT({ scope: 'read:charges' }) + .setProtectedHeader({ alg: 'RS256' }) + .setIssuer(ISSUER) + .setAudience(AUDIENCE) + .setExpirationTime('2h') + .sign(privateKey); + await expect( + verifyAccessTokenWithKey(token, publicKey, { issuer: ISSUER, audience: AUDIENCE }), + ).rejects.toBeInstanceOf(TokenVerificationError); + }); +}); + +describe('setAuthPrincipal / getAuthPrincipal', () => { + const principal: AuthPrincipal = { + subject: 'user-1', + issuer: ISSUER, + audience: AUDIENCE, + scopes: [], + email: null, + expiresAt: undefined, + claims: { sub: 'user-1' }, + }; + + it('round-trips a principal stored on a request', () => { + const request = req(); + expect(getAuthPrincipal(request)).toBeUndefined(); + setAuthPrincipal(request, principal); + expect(getAuthPrincipal(request)).toBe(principal); + }); + + it('keeps principals isolated per request', () => { + const a = req(); + const b = req(); + setAuthPrincipal(a, principal); + expect(getAuthPrincipal(b)).toBeUndefined(); + }); }); diff --git a/packages/mcp-server/src/config/__tests__/env.test.ts b/packages/mcp-server/src/config/__tests__/env.test.ts index 79f4bb24f..6cb08b33c 100644 --- a/packages/mcp-server/src/config/__tests__/env.test.ts +++ b/packages/mcp-server/src/config/__tests__/env.test.ts @@ -1,5 +1,7 @@ -import { describe, expect, it } from 'vitest'; -import { EnvValidationError, parseEnv } from '../env.js'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { EnvValidationError, loadEnv, parseEnv } from '../env.js'; const validEnv: NodeJS.ProcessEnv = { MCP_PUBLIC_BASE_URL: 'https://mcp.example.com', @@ -111,3 +113,42 @@ describe('parseEnv — invalid configuration', () => { expect(() => parseEnv({ ...validEnv, AUTH0_AUDIENCE: '' })).toThrow(EnvValidationError); }); }); + +describe('loadEnv — fail-fast startup', () => { + // Point dotenv at a nonexistent file so no real .env leaks into the source. + const missingEnvFile = resolve(tmpdir(), 'accounter-mcp-nonexistent.env'); + const originalTestEnvFile = process.env.TEST_ENV_FILE; + + afterEach(() => { + if (originalTestEnvFile === undefined) { + delete process.env.TEST_ENV_FILE; + } else { + process.env.TEST_ENV_FILE = originalTestEnvFile; + } + vi.restoreAllMocks(); + }); + + it('reports the invalid variables and exits the process', () => { + process.env.TEST_ENV_FILE = missingEnvFile; + const exit = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`process.exit:${code}`); + }) as never); + const errorLog = vi.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => loadEnv({})).toThrow('process.exit:1'); + expect(exit).toHaveBeenCalledWith(1); + expect(String(errorLog.mock.calls[0]?.[0])).toContain('Invalid environment variables'); + }); + + it('returns a validated config for a valid source without exiting', () => { + process.env.TEST_ENV_FILE = missingEnvFile; + const exit = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit called unexpectedly'); + }) as never); + + const config = loadEnv({ ...validEnv }); + expect(config.server.port).toBe(3100); + expect(config.upstream.graphqlUrl).toBe('http://localhost:4000/graphql'); + expect(exit).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/mcp-server/src/tools/__tests__/charges.test.ts b/packages/mcp-server/src/tools/__tests__/charges.test.ts index bd8ec555f..6d0722fa4 100644 --- a/packages/mcp-server/src/tools/__tests__/charges.test.ts +++ b/packages/mcp-server/src/tools/__tests__/charges.test.ts @@ -174,6 +174,23 @@ describe('searchChargesTool — invalid filters', () => { expect((result.structuredContent as { message: string }).message).toMatch(/on or before/); }); + it('rejects a single impossible toDate that still matches the format', async () => { + const client = clientReturning(oneCharge); + const result = await run(client, authContext(['b1']), { toDate: '2026-13-01' }); + expect(result.isError).toBe(true); + expect((result.structuredContent as { message: string }).message).toBe('Invalid toDate'); + }); + + it('rejects a date range wider than the cap', async () => { + const client = clientReturning(oneCharge); + const result = await run(client, authContext(['b1']), { + fromDate: '2024-01-01', + toDate: '2026-01-01', + }); + expect(result.isError).toBe(true); + expect((result.structuredContent as { message: string }).message).toMatch(/must not exceed/); + }); + it('rejects a page size above the cap', async () => { const client = clientReturning(oneCharge); const result = await run(client, authContext(['b1']), { pageSize: 500 }); diff --git a/packages/mcp-server/src/tools/__tests__/output.test.ts b/packages/mcp-server/src/tools/__tests__/output.test.ts index 72516f4d5..8221682f1 100644 --- a/packages/mcp-server/src/tools/__tests__/output.test.ts +++ b/packages/mcp-server/src/tools/__tests__/output.test.ts @@ -35,6 +35,26 @@ describe('shapeListResult — within limits', () => { }); }); +describe('shapeListResult — default summary', () => { + it('reports "No results." when there are no items', () => { + const result = shapeListResult({ items: [], itemsKey: 'things' }); + expect(result.content[0].text).toBe('No results.'); + const structured = result.structuredContent as { totalCount: number; truncated: boolean }; + expect(structured.totalCount).toBe(0); + expect(structured.truncated).toBe(false); + }); + + it('reports returned-of-total with a truncated marker', () => { + const result = shapeListResult({ items: [{ a: 1 }], itemsKey: 'things', total: 5 }); + expect(result.content[0].text).toBe('Returning 1 of 5 result(s) (truncated).'); + }); + + it('omits the truncated marker when everything is returned', () => { + const result = shapeListResult({ items: [{ a: 1 }, { a: 2 }], itemsKey: 'things' }); + expect(result.content[0].text).toBe('Returning 2 of 2 result(s).'); + }); +}); + describe('shapeListResult — result cap (total > items)', () => { it('marks truncated with a result_cap continuation when more exist upstream', () => { const result = shapeListResult({ items: [{ a: 1 }], itemsKey: 'things', total: 10 }); diff --git a/packages/mcp-server/src/tools/__tests__/reports.test.ts b/packages/mcp-server/src/tools/__tests__/reports.test.ts index e163d6d0c..b87501da8 100644 --- a/packages/mcp-server/src/tools/__tests__/reports.test.ts +++ b/packages/mcp-server/src/tools/__tests__/reports.test.ts @@ -121,6 +121,21 @@ describe('balanceReportTool — invalid range', () => { expect(result.isError).toBe(true); expect((result.structuredContent as { code: string }).code).toBe('VALIDATION_ERROR'); }); + + it('rejects an impossible date that still matches the format', async () => { + const client = clientReturning([]); + // Passes the schema's format regex but is not a real calendar date, so the + // handler's own Date.parse guard (not zod) must reject it. + const result = await run(client, authContext(['b1']), { + businessId: 'b1', + fromDate: '2026-13-01', + toDate: '2026-03-01', + }); + expect(result.isError).toBe(true); + expect((result.structuredContent as { message: string }).message).toBe( + 'Invalid fromDate/toDate', + ); + }); }); describe('balanceReportTool — oversized results', () => { From e5605a3a075c539674591fcd1175bf091c327472 Mon Sep 17 00:00:00 2001 From: Gil Gardosh Date: Thu, 30 Jul 2026 10:48:38 +0300 Subject: [PATCH 33/38] test(mcp-server): end-to-end integration & security suite (Prompt 21) (#4059) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(mcp-server): end-to-end integration & security suite (Prompt 21) Add a service-free e2e suite that drives the real HTTP server in-process over a loopback socket, exercising the full pipeline (routing, OAuth discovery, 401 challenge, bearer verification, upstream membership resolution, per-tool policy, error taxonomy). Only the two true external dependencies are mocked — the Auth0 verifier and the upstream GraphQL client (fixtures keyed by operation). Covers: metadata discovery + health; the 401 challenge (missing and invalid token); authenticated tool invocation (charges search, role-gated balance report); cross-tenant denial (scope outside memberships); unauthorized-role denial; and error-taxonomy mapping (VALIDATION_ERROR incl. impossible dates, unknown tool → InvalidParams, invalid JSON → ParseError). Runs in the DB-free unit project so it stays deterministic and CI-ready. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo * test(mcp-server): vary e2e membership fixture by caller (Prompt 21 review) Address PR review: the myMemberships fixture returned business_owner for every caller, including the roleless viewer-token. Thread the forwarded Authorization value into the fixture so the owner holds a business and the viewer holds none — internally consistent per principal, and robust if role gating ever moves from token scopes to membership roles. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo --------- Co-authored-by: Claude --- .../mcp-server/src/__tests__/mcp-e2e.test.ts | 304 ++++++++++++++++++ 1 file changed, 304 insertions(+) create mode 100644 packages/mcp-server/src/__tests__/mcp-e2e.test.ts diff --git a/packages/mcp-server/src/__tests__/mcp-e2e.test.ts b/packages/mcp-server/src/__tests__/mcp-e2e.test.ts new file mode 100644 index 000000000..f140abc59 --- /dev/null +++ b/packages/mcp-server/src/__tests__/mcp-e2e.test.ts @@ -0,0 +1,304 @@ +import type { AddressInfo } from 'node:net'; +import type { Server } from 'node:http'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { TokenVerificationError, type AuthPrincipal } from '../auth/token.js'; + +/** + * End-to-end integration / security tests. + * + * These drive the real HTTP server in-process over a loopback socket and + * exercise the full request pipeline — routing, OAuth discovery, the 401 + * challenge, bearer verification, upstream membership resolution, per-tool + * authorization, and the error taxonomy. Only the two true external + * dependencies are mocked so the suite stays deterministic and service-free: + * + * - `verifyAccessToken` (would otherwise fetch a remote JWKS), and + * - the upstream GraphQL client (fixtures keyed by operation name). + * + * Everything between — the transport, auth context, policy, and tools — is the + * real production code. + */ + +// --- Mock the Auth0 verifier: map opaque test tokens to principals. --------- +vi.mock('../auth/verifier.js', () => ({ + verifyAccessToken: vi.fn(async (token: string): Promise => { + const base = { + issuer: 'https://tenant.auth0.com/', + audience: 'mcp-api', + expiresAt: undefined, + }; + if (token === 'owner-token') { + return { + ...base, + subject: 'user-owner', + scopes: ['business_owner'], + email: 'owner@example.com', + claims: { sub: 'user-owner' }, + }; + } + if (token === 'viewer-token') { + // Authenticated, but holds no roles/scopes. + return { + ...base, + subject: 'user-viewer', + scopes: [], + email: 'viewer@example.com', + claims: { sub: 'user-viewer' }, + }; + } + throw new TokenVerificationError('token is invalid'); + }), +})); + +// --- Mock the upstream GraphQL client with fixtures by operation. ----------- +const AUTHORIZED_BUSINESS = 'aa000000-0000-4000-8000-000000000001'; + +function upstreamData(query: string, authorization?: string): unknown { + if (query.includes('myMemberships')) { + // Vary memberships by the forwarded caller so the fixture stays consistent + // with each principal: the owner holds a business, the roleless viewer holds + // none. (Keeps the suite honest if role gating ever moves from token scopes + // to membership roles.) + if (authorization === 'Bearer owner-token') { + return { myMemberships: [{ businessId: AUTHORIZED_BUSINESS, roleId: 'business_owner' }] }; + } + return { myMemberships: [] }; + } + if (query.includes('allCharges')) { + return { + allCharges: { + nodes: [ + { + id: 'charge-1', + userDescription: 'Coffee supplies', + totalAmount: { raw: -12.5, formatted: '-12.50', currency: 'ILS' }, + minEventDate: '2026-01-05', + }, + ], + pageInfo: { totalPages: 1, totalRecords: 1, currentPage: null, pageSize: null }, + }, + }; + } + if (query.includes('allTags')) { + return { allTags: [{ id: 'tag-1', name: 'food', namePath: ['food'] }] }; + } + if (query.includes('taxCategories')) { + return { taxCategories: [{ id: 'tc-1', name: 'Income', irsCode: 100, isActive: true }] }; + } + if (query.includes('transactionsForBalanceReport')) { + return { + transactionsForBalanceReport: [ + { + id: 'row-1', + chargeId: 'charge-1', + date: '2026-01-05', + isFee: false, + description: 'Opening balance', + amount: { raw: 100, formatted: '100.00', currency: 'ILS' }, + }, + ], + }; + } + throw new Error(`unexpected upstream query: ${query}`); +} + +const fakeUpstreamClient = { + query: vi.fn(async (request: { query: string }, context: { authorization?: string }) => + upstreamData(request.query, context.authorization), + ), +}; + +vi.mock('../upstream/default-client.js', () => ({ + getUpstreamClient: () => fakeUpstreamClient, + resetUpstreamClient: () => {}, +})); + +// --------------------------------------------------------------------------- + +let server: Server; +let baseUrl: string; + +beforeAll(async () => { + vi.stubEnv('MCP_PUBLIC_BASE_URL', 'https://mcp.example.com'); + vi.stubEnv('AUTH0_ISSUER_URL', 'https://tenant.auth0.com/'); + vi.stubEnv('AUTH0_AUDIENCE', 'mcp-api'); + vi.stubEnv('GRAPHQL_UPSTREAM_URL', 'http://localhost:4000/graphql'); + vi.stubEnv('MCP_ENABLED', '1'); + const { resetEnvCache } = await import('../config/env.js'); + resetEnvCache(); + + const { createHttpServer } = await import('../server.js'); + server = createHttpServer(); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address() as AddressInfo; + baseUrl = `http://127.0.0.1:${port}`; +}); + +afterAll(async () => { + await new Promise((resolve, reject) => + server.close(error => (error ? reject(error) : resolve())), + ); + const { resetEnvCache } = await import('../config/env.js'); + vi.unstubAllEnvs(); + resetEnvCache(); +}); + +type JsonRecord = Record; + +async function getPath(path: string, token?: string) { + const res = await fetch(`${baseUrl}${path}`, { + headers: token ? { authorization: `Bearer ${token}` } : {}, + }); + return res; +} + +async function rpc( + method: string, + { params, token, id = 1 }: { params?: unknown; token?: string; id?: number | string | null } = {}, +) { + const res = await fetch(`${baseUrl}/mcp`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(token ? { authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ jsonrpc: '2.0', id, method, ...(params !== undefined && { params }) }), + }); + return res; +} + +/** Call a registered tool and return its parsed structuredContent + isError. */ +async function callTool(name: string, args: JsonRecord, token: string) { + const res = await rpc('tools/call', { params: { name, arguments: args }, token }); + expect(res.status).toBe(200); + const body = (await res.json()) as { result: { isError?: boolean; structuredContent?: JsonRecord } }; + return body.result; +} + +describe('discovery and the 401 challenge', () => { + it('serves health without auth', async () => { + const res = await getPath('/health'); + expect(res.status).toBe(200); + expect(((await res.json()) as JsonRecord).status).toBe('ok'); + }); + + it('serves OAuth protected-resource metadata', async () => { + const res = await getPath('/.well-known/oauth-protected-resource'); + expect(res.status).toBe(200); + const body = (await res.json()) as JsonRecord; + expect(body.resource).toBe('https://mcp.example.com'); + expect(body.authorization_servers).toEqual(['https://tenant.auth0.com/']); + }); + + it('challenges an unauthenticated POST /mcp with 401 + resource_metadata pointer', async () => { + const res = await rpc('tools/list'); + expect(res.status).toBe(401); + expect(res.headers.get('www-authenticate')).toContain( + 'resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"', + ); + }); + + it('rejects an invalid token with 401 + error="invalid_token"', async () => { + const res = await rpc('tools/list', { token: 'garbage' }); + expect(res.status).toBe(401); + expect(res.headers.get('www-authenticate')).toContain('error="invalid_token"'); + }); +}); + +describe('authenticated tool invocation', () => { + it('initializes and lists the curated tools', async () => { + const initRes = await rpc('initialize', { token: 'owner-token' }); + expect(initRes.status).toBe(200); + expect(((await initRes.json()) as { result: JsonRecord }).result.protocolVersion).toBeTruthy(); + + const listRes = await rpc('tools/list', { token: 'owner-token' }); + const tools = ( + (await listRes.json()) as { result: { tools: Array<{ name: string }> } } + ).result.tools.map(t => t.name); + expect(tools).toEqual( + expect.arrayContaining([ + 'accounter_search_charges', + 'accounter_list_tags', + 'accounter_list_tax_categories', + 'accounter_balance_report', + ]), + ); + }); + + it('runs a charges search scoped to the caller and returns structured results', async () => { + const result = await callTool('accounter_search_charges', { fromDate: '2026-01-01' }, 'owner-token'); + expect(result.isError).toBeUndefined(); + const { charges } = result.structuredContent as { charges: Array<{ id: string }> }; + expect(charges).toHaveLength(1); + expect(charges[0].id).toBe('charge-1'); + }); + + it('runs a role-gated balance report for a caller who holds the role', async () => { + const result = await callTool( + 'accounter_balance_report', + { businessId: AUTHORIZED_BUSINESS, fromDate: '2026-01-01', toDate: '2026-01-31' }, + 'owner-token', + ); + expect(result.isError).toBeUndefined(); + const { rows } = result.structuredContent as { rows: unknown[] }; + expect(rows).toHaveLength(1); + }); +}); + +describe('tenant isolation and authorization', () => { + it('denies a charges search narrowed to a business outside the memberships', async () => { + const result = await callTool( + 'accounter_search_charges', + { businessIds: ['bb000000-0000-4000-8000-000000000999'] }, + 'owner-token', + ); + expect(result.isError).toBe(true); + expect((result.structuredContent as { code: string }).code).toBe('AUTHORIZATION_ERROR'); + }); + + it('denies a role-gated tool to an authenticated caller without the role', async () => { + const result = await callTool( + 'accounter_balance_report', + { businessId: AUTHORIZED_BUSINESS, fromDate: '2026-01-01', toDate: '2026-01-31' }, + 'viewer-token', + ); + expect(result.isError).toBe(true); + expect((result.structuredContent as { code: string }).code).toBe('AUTHORIZATION_ERROR'); + }); +}); + +describe('malformed input and error taxonomy', () => { + it('maps an unknown tool-input field to a VALIDATION_ERROR with issues', async () => { + const result = await callTool('accounter_search_charges', { bogusField: true }, 'owner-token'); + expect(result.isError).toBe(true); + const structured = result.structuredContent as { code: string; issues?: unknown[] }; + expect(structured.code).toBe('VALIDATION_ERROR'); + expect(Array.isArray(structured.issues)).toBe(true); + }); + + it('maps an impossible calendar date to a VALIDATION_ERROR', async () => { + const result = await callTool('accounter_search_charges', { toDate: '2026-02-31' }, 'owner-token'); + expect(result.isError).toBe(true); + expect((result.structuredContent as { code: string }).code).toBe('VALIDATION_ERROR'); + }); + + it('maps a non-existent tool name to a JSON-RPC InvalidParams error', async () => { + const res = await rpc('tools/call', { + params: { name: 'accounter_not_a_tool', arguments: {} }, + token: 'owner-token', + }); + const body = (await res.json()) as { error?: { code: number; message: string } }; + expect(body.error?.code).toBe(-32602); + expect(body.error?.message).toContain('Unknown tool'); + }); + + it('maps invalid JSON to a JSON-RPC ParseError', async () => { + const res = await fetch(`${baseUrl}/mcp`, { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: 'Bearer owner-token' }, + body: '{ not json', + }); + const body = (await res.json()) as { error?: { code: number } }; + expect(body.error?.code).toBe(-32700); + }); +}); From 098cf9750a055a1e630ec4531b958337f41dba01 Mon Sep 17 00:00:00 2001 From: Gil Gardosh Date: Thu, 30 Jul 2026 10:54:30 +0300 Subject: [PATCH 34/38] test(mcp-server): performance & timeout validation (Prompt 22) (#4060) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(mcp-server): performance & timeout validation (Prompt 22) Add a lightweight, service-free performance suite for phase-1 read-only workloads plus a `benchmark` script and a generated summary artifact. - Load: true per-call latency measured sequentially (p95 asserted < 25ms) and a fixed-concurrency worker pool draining 500 calls to confirm sustained throughput with zero errors. The upstream is an injected fetchImpl, so numbers reflect our pipeline overhead, not a network. - Timeout & retry: a slow upstream past the budget aborts and surfaces a retryable TIMEOUT_ERROR (1 + maxRetries attempts observed); a delayed-but-within-budget response succeeds; a non-retryable validation error does not loop. - Writes a short benchmark summary artifact to packages/mcp-server/bench/ (git-ignored); `yarn workspace @accounter/mcp-server benchmark` runs it. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo * test(mcp-server): perf-suite review fixes (Prompt 22 review) - Worker pool claims each slot with an atomic read-then-increment (`const current = issued++`) so exactly LOAD_CALLS run, never over-issued. - delayedFetch handles an already-aborted signal up front and uses a `{ once: true }` abort listener, matching real fetch and avoiding a hang. - percentile() uses nearest-rank (`ceil(q·n)-1`) so p95 of 100 samples is the 95th value, not the 96th — steadier threshold assertions. - benchmark script runs through the repo's DB-free `unit` Vitest project (matching the documented command / CI setup) instead of a package-local run. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo --------- Co-authored-by: Claude --- packages/mcp-server/.gitignore | 2 + packages/mcp-server/package.json | 1 + .../mcp-server/src/__tests__/perf.test.ts | 284 ++++++++++++++++++ 3 files changed, 287 insertions(+) create mode 100644 packages/mcp-server/.gitignore create mode 100644 packages/mcp-server/src/__tests__/perf.test.ts diff --git a/packages/mcp-server/.gitignore b/packages/mcp-server/.gitignore new file mode 100644 index 000000000..ffef9a643 --- /dev/null +++ b/packages/mcp-server/.gitignore @@ -0,0 +1,2 @@ +# Generated benchmark artifacts (Prompt 22) +bench/ diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 0b06ab47a..88156e5e7 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -30,6 +30,7 @@ "accounter" ], "scripts": { + "benchmark": "cd ../.. && vitest run --project unit packages/mcp-server/src/__tests__/perf.test.ts", "build": "tsc", "dev": "tsx watch src/index.ts", "lint": "eslint './src/**/*.{js,ts,tsx}' --quiet", diff --git a/packages/mcp-server/src/__tests__/perf.test.ts b/packages/mcp-server/src/__tests__/perf.test.ts new file mode 100644 index 000000000..78b8fe131 --- /dev/null +++ b/packages/mcp-server/src/__tests__/perf.test.ts @@ -0,0 +1,284 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, describe, expect, it, vi } from 'vitest'; +import { buildAuthContext, type BusinessMembership } from '../auth/identity.js'; +import type { AuthPrincipal } from '../auth/token.js'; +import { searchChargesTool } from '../tools/charges.js'; +import { executeRegisteredTool } from '../tools/execute.js'; +import { UpstreamError, UpstreamGraphQLClient } from '../upstream/graphql-client.js'; + +/** + * Practical performance & timeout validation for phase-1 read-only workloads + * (blueprint Prompt 22). Deterministic and service-free: the upstream is an + * injected `fetchImpl`, so the numbers reflect our own pipeline overhead + * (validation → policy → handler → client), not a real network. + * + * Kept lightweight enough to run in the normal suite; it also writes a short + * benchmark summary artifact to `packages/mcp-server/bench/summary.md`. + */ + +const BUSINESS = 'aa000000-0000-4000-8000-000000000001'; + +function authContext() { + const principal: AuthPrincipal = { + subject: 'user-perf', + issuer: 'https://tenant.auth0.com/', + audience: 'mcp-api', + scopes: ['business_owner'], + email: 'perf@example.com', + expiresAt: undefined, + claims: { sub: 'user-perf' }, + }; + const memberships: BusinessMembership[] = [{ businessId: BUSINESS, roleId: 'business_owner' }]; + return buildAuthContext(principal, memberships); +} + +const CHARGES_RESPONSE = { + data: { + allCharges: { + nodes: [ + { + id: 'charge-1', + userDescription: 'Coffee', + totalAmount: { raw: -12.5, formatted: '-12.50', currency: 'ILS' }, + minEventDate: '2026-01-05', + }, + ], + pageInfo: { totalPages: 1, totalRecords: 1, currentPage: null, pageSize: null }, + }, + }, +}; + +/** + * A `fetch` that resolves after `delayMs`, honoring the abort signal. A zero + * delay resolves immediately (no timer) so the load scenario measures pure + * pipeline overhead rather than macrotask scheduling. + */ +function delayedFetch(delayMs: number, body: unknown): typeof fetch { + return ((_url: string, init?: { signal?: AbortSignal }) => { + if (delayMs === 0) { + return Promise.resolve({ ok: true, status: 200, json: async () => body } as Response); + } + return new Promise((resolve, reject) => { + const signal = init?.signal; + const abortError = () => Object.assign(new Error('aborted'), { name: 'AbortError' }); + // Match real fetch: if the signal is already aborted, reject immediately + // rather than waiting for an 'abort' event that will never fire. + if (signal?.aborted) { + reject(abortError()); + return; + } + const timer = setTimeout( + () => resolve({ ok: true, status: 200, json: async () => body } as Response), + delayMs, + ); + signal?.addEventListener( + 'abort', + () => { + clearTimeout(timer); + reject(abortError()); + }, + { once: true }, + ); + }); + }) as unknown as typeof fetch; +} + +function fastClient(): UpstreamGraphQLClient { + return new UpstreamGraphQLClient({ + endpoint: 'http://upstream.local/graphql', + timeoutMs: 1000, + fetchImpl: delayedFetch(0, CHARGES_RESPONSE), + }); +} + +function percentile(sorted: number[], q: number): number { + if (sorted.length === 0) return 0; + // Nearest-rank: the q-th percentile is the ceil(q·n)-th value (1-indexed), so + // p95 of 100 samples is the 95th value, not the 96th. + const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(q * sorted.length) - 1)); + return sorted[index]; +} + +// Generous CI-safe ceilings for our in-process overhead per read-only call. +const P95_TARGET_MS = 25; +const LOAD_CALLS = 500; +const CONCURRENCY = 50; + +let summary: { + latencySamples: number; + p50: number; + p95: number; + max: number; + loadCalls: number; + concurrency: number; + throughputPerSec: number; +} | null = null; + +async function callCharges(client: UpstreamGraphQLClient, auth = authContext()) { + return executeRegisteredTool({ + tool: searchChargesTool, + rawArgs: { fromDate: '2026-01-01' }, + auth, + correlationId: 'perf', + client, + }); +} + +describe('read-only tool call load', () => { + it('keeps per-call p95 latency within target', async () => { + const auth = authContext(); + const client = fastClient(); + + // True per-call latency: measured sequentially so the sample reflects + // pipeline cost, not queueing behind concurrent calls. Warm up first. + for (let i = 0; i < 20; i++) await callCharges(client, auth); + + const durations: number[] = []; + for (let i = 0; i < 100; i++) { + const start = performance.now(); + const result = await callCharges(client, auth); + durations.push(performance.now() - start); + expect(result.isError).toBeUndefined(); + } + durations.sort((a, b) => a - b); + + const partial = { + latencySamples: durations.length, + p50: percentile(durations, 0.5), + p95: percentile(durations, 0.95), + max: durations[durations.length - 1], + }; + summary = { ...partial, loadCalls: 0, concurrency: 0, throughputPerSec: 0 }; + + expect(partial.p95).toBeLessThan(P95_TARGET_MS); + }); + + it('sustains throughput under baseline concurrency with no errors', async () => { + const auth = authContext(); + const client = fastClient(); + + const start = performance.now(); + let completed = 0; + // Fixed-size worker pool draining a shared counter keeps `CONCURRENCY` + // calls in flight at once. + let issued = 0; + await Promise.all( + Array.from({ length: CONCURRENCY }, async () => { + for (;;) { + // Claim a slot atomically (read-then-increment with no await between) + // so the pool issues exactly LOAD_CALLS across all workers. + const current = issued++; + if (current >= LOAD_CALLS) break; + const result = await callCharges(client, auth); + expect(result.isError).toBeUndefined(); + completed++; + } + }), + ); + const elapsedMs = performance.now() - start; + const throughputPerSec = (completed / elapsedMs) * 1000; + + expect(completed).toBe(LOAD_CALLS); + if (summary) { + summary = { ...summary, loadCalls: completed, concurrency: CONCURRENCY, throughputPerSec }; + } + }); +}); + +describe('timeout and retry behavior', () => { + it('aborts a slow upstream past the budget and surfaces a retryable TIMEOUT_ERROR', async () => { + const fetchImpl = vi.fn(delayedFetch(100, CHARGES_RESPONSE)); + const client = new UpstreamGraphQLClient({ + endpoint: 'http://upstream.local/graphql', + timeoutMs: 25, + maxRetries: 2, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + + await expect( + client.query({ query: 'query { x }' }, { correlationId: 'perf' }), + ).rejects.toMatchObject({ code: 'TIMEOUT_ERROR', retryable: true }); + + // 1 initial attempt + 2 retries, since a timeout is retryable. + expect(fetchImpl).toHaveBeenCalledTimes(3); + }); + + it('does not retry a validation/handler error (only retryable failures loop)', async () => { + const client = fastClient(); + const result = await executeRegisteredTool({ + tool: searchChargesTool, + rawArgs: { fromDate: '2026-13-40' }, // impossible date → VALIDATION_ERROR, no upstream call + auth: authContext(), + correlationId: 'perf', + client, + }); + expect(result.isError).toBe(true); + expect((result.structuredContent as { code: string }).code).toBe('VALIDATION_ERROR'); + }); + + it('completes a delayed-but-within-budget upstream response successfully', async () => { + const client = new UpstreamGraphQLClient({ + endpoint: 'http://upstream.local/graphql', + timeoutMs: 200, + fetchImpl: delayedFetch(15, CHARGES_RESPONSE), + }); + const data = await client.query<{ allCharges: { nodes: unknown[] } }>( + { query: 'query { allCharges { nodes { id } } }' }, + { correlationId: 'perf' }, + ); + expect(data.allCharges.nodes).toHaveLength(1); + }); + + it('is UpstreamError, not a raw error, when the budget is exceeded', async () => { + const client = new UpstreamGraphQLClient({ + endpoint: 'http://upstream.local/graphql', + timeoutMs: 10, + maxRetries: 0, + fetchImpl: delayedFetch(100, CHARGES_RESPONSE), + }); + await expect( + client.query({ query: 'query { x }' }, { correlationId: 'perf' }), + ).rejects.toBeInstanceOf(UpstreamError); + }); +}); + +afterAll(() => { + if (!summary) return; + const artifactPath = fileURLToPath(new URL('../../bench/summary.md', import.meta.url)); + const lines = [ + '# MCP server — phase 1 benchmark summary', + '', + '_Generated by `packages/mcp-server/src/__tests__/perf.test.ts`. Numbers reflect in-process', + 'pipeline overhead (validation → policy → handler → upstream client) with a mocked upstream._', + '', + '## Read-only tool call latency', + '', + `- Scenario: \`accounter_search_charges\` (${summary.latencySamples} sequential samples)`, + `- p50: ${summary.p50.toFixed(3)} ms`, + `- p95: ${summary.p95.toFixed(3)} ms (target < ${P95_TARGET_MS} ms)`, + `- max: ${summary.max.toFixed(3)} ms`, + '', + '## Throughput under baseline concurrency', + '', + `- ${summary.loadCalls} calls at concurrency ${summary.concurrency}`, + `- throughput: ${summary.throughputPerSec.toFixed(0)} calls/sec (0 errors)`, + '', + '## Timeout & retry', + '', + '- A slow upstream past the timeout budget aborts and surfaces a retryable `TIMEOUT_ERROR`.', + '- Retryable failures loop up to `maxRetries` (1 + 2 attempts observed); non-retryable', + ' validation/handler errors do not retry.', + '- A delayed-but-within-budget response completes successfully.', + '', + ]; + mkdirSync(dirname(artifactPath), { recursive: true }); + writeFileSync(artifactPath, lines.join('\n'), 'utf8'); + // eslint-disable-next-line no-console + console.log( + `[perf] p50=${summary.p50.toFixed(2)}ms p95=${summary.p95.toFixed(2)}ms max=${summary.max.toFixed( + 2, + )}ms (${summary.concurrency} concurrent) → ${artifactPath}`, + ); +}); From 4d363ca9069eb3de80ca64222ce16c5e4acaa578 Mon Sep 17 00:00:00 2001 From: Gil Gardosh Date: Thu, 30 Jul 2026 11:02:34 +0300 Subject: [PATCH 35/38] docs(mcp-server): deployment README, ops runbook, submission checklist (Prompt 23) (#4061) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(mcp-server): deployment README, ops runbook, submission checklist (Prompt 23) - README: refresh the status to phase-1-complete; correct the identity section to reflect server-resolved memberships (myMemberships); add a Smoke test section (health, discovery, 401 challenge, authenticated tools/list + tools/call), a Troubleshooting table, a Known-limitations & phase-2 (write scope) section, and the benchmark script. - docs/mcp/operations-runbook.md: service overview, key metrics, log queries, incident playbooks (401s, upstream errors, rate limiting, tenant-isolation), and rollback (kill-switch, version rollback, allow-list). - docs/mcp/submission-checklist.md: connector submission readiness aligned with the current implementation, plus known gaps to disclose. Docs describe only implemented behavior. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo * docs(mcp-server): correct kill-switch scope and allowlist status (Prompt 23 review) Address PR review — docs must match implemented behavior: - Kill-switch: the README troubleshooting row and the runbook (overview + the tenant-isolation playbook) said MCP_ENABLED=0 serves "health only". It also keeps serving /metrics; only /mcp and the OAuth metadata route are 404'd. - MCP_TOOL_ALLOWLIST is only parsed at startup, not enforced anywhere. Removed the "narrow exposure" rollback claim (replaced with a not-yet-enforced note) and unchecked the allow-list item in the submission checklist. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo --------- Co-authored-by: Claude --- docs/mcp/operations-runbook.md | 96 +++++++++++++++++++++++++++ docs/mcp/submission-checklist.md | 84 ++++++++++++++++++++++++ packages/mcp-server/README.md | 109 +++++++++++++++++++++++++------ 3 files changed, 270 insertions(+), 19 deletions(-) create mode 100644 docs/mcp/operations-runbook.md create mode 100644 docs/mcp/submission-checklist.md diff --git a/docs/mcp/operations-runbook.md b/docs/mcp/operations-runbook.md new file mode 100644 index 000000000..146ad9b66 --- /dev/null +++ b/docs/mcp/operations-runbook.md @@ -0,0 +1,96 @@ +# MCP Server — Operations Runbook + +Operational reference for the `@accounter/mcp-server` remote connector (phase 1, read-only). Scope +is limited to behavior actually implemented in `packages/mcp-server`. + +## 1. Service overview + +- **Process**: a single Node HTTP server (`packages/mcp-server/src/server.ts`), started via + `src/index.ts`. Stateless — no database of its own; all data comes from the Accounter GraphQL + server over HTTP. +- **Endpoints**: + - `GET /health` — liveness/readiness (no auth). + - `GET /metrics` — in-process telemetry snapshot (JSON, no auth). + - `GET /.well-known/oauth-protected-resource` — RFC 9728 discovery (no auth). + - `GET|POST /mcp` — MCP transport (JSON-RPC 2.0), bearer-authenticated. +- **Kill-switch**: `MCP_ENABLED=0` disables only the MCP transport (`/mcp`) and its OAuth metadata + route (both return `404`); `/health` and `/metrics` stay available. +- **Shutdown**: `SIGINT`/`SIGTERM` drains connections, then exits (forced after a grace period). + +## 2. Key metrics (`GET /metrics`) + +Labels never carry PII — only tool names, outcome classes, and error categories. + +| Metric | Shape | Watch for | +| --------------------- | ---------------------------------------------------- | ------------------------------------------------------------------- | +| `requestsTotal` | counter keyed `"\|"` | Rising `*_error` outcomes; ratio of `success` to errors per tool. | +| `latencyMs` | histogram (per-bucket counts + count/sum) | p95/p99 drift; `sum/count` mean latency creeping up. | +| `authFailuresTotal` | counter by reason (`missing_token`, `invalid_token`) | Spikes in `invalid_token` → token/audience misconfig or abuse. | +| `upstreamErrorsTotal` | counter by category | Sustained `TIMEOUT_ERROR`/`UPSTREAM_ERROR` → upstream health issue. | +| `rateLimitedTotal` | counter | Sustained growth → limits too tight or a hot client. | + +Baseline latency targets and the load profile are captured by +`packages/mcp-server/src/__tests__/perf.test.ts` (`yarn workspace @accounter/mcp-server benchmark`). + +## 3. Logs + +Structured JSON, one object per line. Every request carries `requestId` and `correlationId` (the +latter inherited from an inbound `X-Correlation-Id` header when present, and echoed on the +response + propagated upstream). Secrets and `Authorization` headers are never logged. + +Useful queries (adapt to your log backend): + +- **Trace one request across hops**: filter by `correlationId == ""` (spans MCP → GraphQL). +- **Auth failures**: `message == "access token verification failed"` (carries `reason`, never the + token). +- **Upstream failures**: `message` starting `span end` with `name == "upstream:graphql"` and a + non-zero error, or tool results logged with `code` in (`UPSTREAM_ERROR`, `TIMEOUT_ERROR`). +- **Slow requests**: completion logs where `latencyMs` exceeds your target. +- **Unexpected tool errors**: `message == "unexpected error during tool execution"` (carries `tool`, + `correlationId`, and the sanitized error) — these map to `INTERNAL_ERROR` for callers. + +## 4. Incident playbooks + +### A. Elevated `401`s / all calls failing auth + +1. Check `authFailuresTotal` split: `missing_token` vs `invalid_token`. +2. `invalid_token` spike → verify `AUTH0_ISSUER_URL` and `AUTH0_AUDIENCE` match the tokens clients + present, and the tenant JWKS is reachable. A JWKS outage surfaces as a `5xx` (not `401`). +3. Confirm the clock/expiry: expired tokens are `invalid_token` by design. + +### B. Elevated `UPSTREAM_ERROR` / `TIMEOUT_ERROR` + +1. Check the Accounter GraphQL server health and network path (`GRAPHQL_UPSTREAM_URL`). +2. Timeouts are bounded-retried; persistent 5xx exhaust retries then surface as errors. Consider + raising `GRAPHQL_UPSTREAM_TIMEOUT_MS` only if the upstream is legitimately slow. +3. 4xx/GraphQL-level errors are **not** retried — investigate the upstream, not the connector. + +### C. Elevated `RATE_LIMIT_ERROR` + +1. Inspect `rateLimitedTotal` and the offending `{user, scope, tool}` pattern in logs. +2. Adjust `MCP_RATE_LIMIT_CONFIG` (`{"windowMs":60000,"max":60}` default) if limits are too tight. + +### D. Suspected data-exposure / tenant-isolation concern + +1. Treat as high severity. **Flip the kill-switch**: set `MCP_ENABLED=0` and restart — `/mcp` and + its metadata route return `404` (`/health` and `/metrics` stay up). +2. Memberships are resolved server-side from `business_users` via `myMemberships`; scope narrowing + outside memberships is denied (`AUTHORIZATION_ERROR`). Verify the server-side rows and the + caller's token. + +## 5. Rollback + +- **Fastest mitigation**: `MCP_ENABLED=0` (kill-switch) — disables the connector while keeping the + process/health up. No redeploy needed if env can be changed and the process restarted. +- **Version rollback**: redeploy the previous image/build of `@accounter/mcp-server`. The service is + stateless, so rollback is safe and requires no data migration. + +> **Note:** `MCP_TOOL_ALLOWLIST` is parsed at startup but **not yet enforced** — it does not +> currently restrict which tools are advertised or callable. Do not rely on it as a mitigation until +> enforcement lands; use the kill-switch instead. + +## 6. Configuration reference + +See [`packages/mcp-server/README.md`](../../packages/mcp-server/README.md#configuration) for the +full env-var table. Required: `MCP_PUBLIC_BASE_URL`, `AUTH0_ISSUER_URL`, `AUTH0_AUDIENCE`, +`GRAPHQL_UPSTREAM_URL`. The process fails fast at startup on any missing/malformed value. diff --git a/docs/mcp/submission-checklist.md b/docs/mcp/submission-checklist.md new file mode 100644 index 000000000..ed3cc7929 --- /dev/null +++ b/docs/mcp/submission-checklist.md @@ -0,0 +1,84 @@ +# MCP Connector — Submission Readiness Checklist + +Readiness checklist for submitting `@accounter/mcp-server` as a remote MCP connector. Each item +reflects behavior implemented in `packages/mcp-server`; boxes are checked only where the current +code satisfies them. + +## Transport & protocol + +- [x] Streamable HTTP transport at `POST /mcp`, JSON-RPC 2.0. +- [x] Implements `initialize` (advertises `protocolVersion` + tool capability), `ping`, + `tools/list`, `tools/call`. +- [x] Unknown methods → deterministic JSON-RPC `-32601`; notifications → `202 Accepted`, no body. +- [x] Malformed input mapped to correct JSON-RPC errors (parse `-32700`, invalid request/params + `-32600`/`-32602`); request body size is bounded. +- [x] `GET /mcp` (server-initiated SSE) intentionally unsupported in phase 1 → `405`. + +## Authentication & OAuth discovery + +- [x] `GET /.well-known/oauth-protected-resource` (RFC 9728), fully config-driven. +- [x] Bearer tokens accepted via the `Authorization` header only — never query params + (`bearer_methods_supported: ["header"]`). +- [x] Auth0 access-token verification: signature via tenant JWKS, plus `issuer`, `audience`, expiry. +- [x] Missing token → `401` with a `WWW-Authenticate` `resource_metadata` pointer; invalid/expired → + `401` `error="invalid_token"`. Infra failures (e.g. JWKS outage) surface as `5xx`, not a + misleading `401`. +- [x] Redirect URI to allow-list for hosted Claude: `https://claude.ai/api/mcp/auth_callback` (see + `docs/mcp/spec.md` §6.3). + +## Authorization & tenant isolation + +- [x] Business memberships resolved server-side (`myMemberships`), never from token claims. +- [x] Per-tool authorization policy (required roles + business-scope) enforced **before** any + upstream call. +- [x] Requested scope narrowing validated as a subset of memberships; out-of-scope requests denied + (`AUTHORIZATION_ERROR`), never silently dropped. +- [x] Read-only phase 1: no mutations/subscriptions; no generic query surface. + +## Tools & responses + +- [x] Curated tool set: `accounter_search_charges`, `accounter_list_tags`, + `accounter_list_tax_categories`, `accounter_balance_report`. +- [x] Strict input schemas (unknown fields rejected); advertised JSON Schema matches runtime + validation (`additionalProperties: false`). +- [x] Bounded, deterministic responses (date-range ≤ 366 days, page size ≤ 50, list caps 500, + payload-size guard with `truncated`/`continuation` hints). +- [x] Unified error taxonomy returned as tool results (`isError` + + `{ code, message, correlationId, retryable? }`), with sanitized `INTERNAL_ERROR` for + unexpected failures. + +## Reliability & abuse protection + +- [x] Hardened upstream client: per-request timeout with cancellation, bounded retries for + idempotent reads only, sanitized errors. +- [x] Per-`tools/call` rate limiting keyed by `{user, business scope, tool}` → `RATE_LIMIT_ERROR` + with `retryAfterMs`. +- [x] Kill-switch (`MCP_ENABLED=0`) for fast mitigation (disables `/mcp` + its metadata route). +- [ ] Tool allow-list (`MCP_TOOL_ALLOWLIST`) — parsed at startup but **not yet enforced** (does not + restrict advertised/callable tools). +- [x] Graceful shutdown on `SIGINT`/`SIGTERM`. + +## Observability + +- [x] Structured request/completion logs with `requestId`/`correlationId`; secrets/tokens never + logged. +- [x] Metrics snapshot at `GET /metrics` (request/outcome counters, latency histogram, auth-failure + and upstream-error counters, rate-limited total). +- [x] Correlation id propagated upstream via `X-Correlation-Id`. +- [x] Tracing spans for `auth:verify`, `tool:`, `upstream:graphql` (structured logs). + +## Testing & docs + +- [x] Unit test matrix across config, auth, policy, tools, output, taxonomy, rate limiting, metrics. +- [x] End-to-end integration/security suite (`src/__tests__/mcp-e2e.test.ts`): discovery, 401 + challenge, authenticated invocation, cross-tenant denial, unauthorized role, error taxonomy. +- [x] Performance/timeout suite with a benchmark artifact (`src/__tests__/perf.test.ts`). +- [x] Package README (run/env/troubleshooting/smoke test), operations runbook, and this checklist. + +## Known gaps to disclose at submission + +- [ ] Phase 2 write scope (mutating tools) — **not** implemented (by design; see the README). +- [ ] Metrics/rate-limiting are in-process per replica (no shared store / Prometheus exposition + yet). +- [ ] Tracing is a dependency-free stub (not wired to OpenTelemetry). +- [ ] Loopback/Claude Code callback support is a later phase (hosted-Claude callback only for now). diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 47c997c9e..9f4c832e8 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -8,21 +8,26 @@ See the design docs: - [`docs/mcp/spec.md`](../../docs/mcp/spec.md) — connector specification - [`docs/mcp/implementation-blueprint.md`](../../docs/mcp/implementation-blueprint.md) — incremental implementation plan +- [`docs/mcp/operations-runbook.md`](../../docs/mcp/operations-runbook.md) — incident handling, + metrics, log queries, rollback +- [`docs/mcp/submission-checklist.md`](../../docs/mcp/submission-checklist.md) — connector + submission readiness ## Status -Early scaffolding. This package is being built incrementally following the prompt pack in the -implementation blueprint. It currently contains the package skeleton, strict environment -configuration, a minimal HTTP server with a `/health` endpoint and graceful shutdown, an MCP -transport route (`POST /mcp`) that speaks JSON-RPC 2.0 and lists an internal smoke tool, per-request -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. 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. Operational telemetry -(request/outcome counters, a latency histogram, auth-failure counters, and tracing spans) is -recorded per process and exposed at `GET /metrics`. +Phase 1 (read-only) is feature-complete. The server provides: strict startup env validation; an HTTP +transport with `/health`, `/metrics`, the OAuth protected-resource metadata endpoint, and the MCP +route (`POST /mcp`, JSON-RPC 2.0) with graceful shutdown; Auth0 bearer-token verification; identity +mapping to an internal user + business-membership context with memberships resolved from the +Accounter GraphQL server; a curated registry of four read-only tools (`accounter_search_charges`, +`accounter_list_tags`, `accounter_list_tax_categories`, `accounter_balance_report`) each gated by +strict input validation, a per-tool authorization policy, and business-scope narrowing; a hardened +upstream GraphQL client (timeout, bounded retries, header propagation, sanitized errors); a unified +error taxonomy; per-`tools/call` rate limiting; and operational telemetry (request/outcome counters, +a latency histogram, auth-failure counters, tracing spans) exposed at `GET /metrics`. + +Phase 2 (write scope) is **not** implemented — see +[Known limitations & phase 2](#known-limitations--phase-2-write-scope). ## Tools @@ -69,11 +74,13 @@ generic "execute anything" surface — tools use typed read-only wrappers via `c ## Identity & tenant scope A verified token is mapped to an `McpAuthContext` — `userId`, `roles` (token scopes), business -`memberships`, and a `defaultReadScope` (every business the user belongs to). Memberships are read -from the token's `memberships` custom claim by default (the source is pluggable so a later step can -resolve them from the GraphQL upstream instead). Requested scope narrowing is validated against the -user's memberships: any business id outside them is rejected rather than silently dropped. These -shapes and rules mirror the server package's tenant-isolation model +`memberships`, and a `defaultReadScope` (every business the user belongs to). Memberships are +resolved from the Accounter GraphQL server by forwarding the caller's bearer token to a dedicated +`myMemberships` query (`src/upstream/memberships.ts`) — never derived from token claims — so tenant +membership is always authoritative from the server's database. The membership source is a pluggable +seam (`MembershipSource`) for testing. Requested scope narrowing is validated against the user's +memberships: any business id outside them is rejected rather than silently dropped. These shapes and +rules mirror the server package's tenant-isolation model (`packages/server/src/shared/helpers/auth-scope.ts`). ## OAuth discovery @@ -149,8 +156,8 @@ bearer token in the `Authorization` header. The token is verified (signature via plus `issuer`, `audience`, and expiry). A request with no token gets a `401` pointing at the protected-resource metadata document; a request with an invalid/expired token gets a `401` with `error="invalid_token"`. Supported methods: `initialize`, `ping`, `tools/list`, and `tools/call` -(for the internal `accounter_smoke_ping` tool). Unknown methods return a deterministic JSON-RPC -`-32601` error; notifications receive `202 Accepted` with no body. +(the four curated tools plus the internal `accounter_smoke_ping` tool). Unknown methods return a +deterministic JSON-RPC `-32601` error; notifications receive `202 Accepted` with no body. ```bash curl -sX POST http://localhost:3100/mcp -H 'Content-Type: application/json' \ @@ -185,4 +192,68 @@ yarn workspace @accounter/mcp-server dev # run entrypoint with tsx (watch) yarn workspace @accounter/mcp-server lint # eslint yarn workspace @accounter/mcp-server test # vitest (package-scoped) yarn workspace @accounter/mcp-server typecheck # tsc --noEmit +yarn workspace @accounter/mcp-server benchmark # perf/timeout suite → bench/summary.md +``` + +## Smoke test + +With the four required env vars set and the server running +(`yarn workspace @accounter/mcp-server dev`): + +```bash +# 1. Health (no auth) → 200 {"status":"ok",...} +curl -s http://localhost:3100/health + +# 2. OAuth discovery (no auth) → resource + authorization_servers +curl -s http://localhost:3100/.well-known/oauth-protected-resource + +# 3. Unauthenticated MCP call → 401 with a WWW-Authenticate resource_metadata pointer +curl -si -X POST http://localhost:3100/mcp -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | grep -i -E 'HTTP/|www-authenticate' + +# 4. Authenticated tool list → the four curated tools (+ the smoke tool) +TOKEN= +curl -s -X POST http://localhost:3100/mcp -H 'Content-Type: application/json' \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' + +# 5. Authenticated tool call +curl -s -X POST http://localhost:3100/mcp -H 'Content-Type: application/json' \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"accounter_list_tags","arguments":{}}}' ``` + +The automated equivalent of steps 1–5 (with the Auth0 verifier and upstream mocked) lives in +`src/__tests__/mcp-e2e.test.ts` and runs with `yarn workspace @accounter/mcp-server test`. + +## Troubleshooting + +| Symptom | Likely cause / fix | +| ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Process exits at startup with `[env] Invalid environment …` | A required env var is missing/malformed. The printed report lists each offending key; fix and restart. Required: `MCP_PUBLIC_BASE_URL`, `AUTH0_ISSUER_URL`, `AUTH0_AUDIENCE`, `GRAPHQL_UPSTREAM_URL`. | +| `POST /mcp` returns `401` with no `error` | No bearer token. The `WWW-Authenticate` header points at the metadata document. | +| `POST /mcp` returns `401` with `error="invalid_token"` | Token failed verification (signature/JWKS, `iss`, `aud`, or expiry). Confirm the token's audience matches `AUTH0_AUDIENCE` and the issuer matches `AUTH0_ISSUER_URL`. | +| `/mcp` and `/.well-known/...` return `404` (`/health` + `/metrics` still `200`) | The kill-switch is on (`MCP_ENABLED=0`) — only the MCP transport and its OAuth metadata route are disabled; `/health` and `/metrics` stay up. Set `MCP_ENABLED=1`. | +| Tool result `isError: true`, code `UPSTREAM_ERROR`/`TIMEOUT_ERROR` | The Accounter GraphQL server was unreachable/slow. Check `GRAPHQL_UPSTREAM_URL` and `GRAPHQL_UPSTREAM_TIMEOUT_MS`; timeouts are retried (bounded), 4xx/GraphQL errors are not. | +| Tool result code `AUTHORIZATION_ERROR` | The caller lacks a required role, requested a business outside their memberships, or has no memberships. Verify the token's scopes and the server-side `business_users` rows. | +| Tool result code `RATE_LIMIT_ERROR` with `retryAfterMs` | Per-`{user, scope, tool}` window exceeded. Back off for `retryAfterMs`, or tune `MCP_RATE_LIMIT_CONFIG`. | + +## Known limitations & phase 2 (write scope) + +Phase 1 is intentionally **read-only** and single-purpose: + +- Only the four read-only tools above are exposed; there is no generic "run any query" surface and + **no mutations/subscriptions** (the upstream client refuses them). +- Responses are **bounded** (date ranges ≤ 366 days, page size ≤ 50, list caps of 500, a + payload-size guard) — very large result sets are truncated with a `truncated`/`continuation` hint + rather than streamed in full. +- Rate limiting and metrics are **in-process** (per replica); there is no shared/Redis-backed + limiter or Prometheus exposition yet (the limiter and metrics are behind swappable seams). +- Tracing is a dependency-free stub emitting structured span logs; it is not wired to OpenTelemetry. + +**Phase 2 (write scope)** — not implemented — will add mutating tools (e.g. tagging/updating +charges) behind: per-tool write policies and role checks reusing the server's authorization model; +the server's accountant-approval degradation on charge-mutating operations; write-target business +resolution (a single owning business per write, versus phase-1 multi-business read scope); and +idempotency/audit for writes. Until then, all tools are safe to expose to read-only assistant +workflows. From 57ca97a18cff1b0f4a39808343fbea3a8defdadf Mon Sep 17 00:00:00 2001 From: Gil Gardosh Date: Thu, 30 Jul 2026 11:25:31 +0300 Subject: [PATCH 36/38] lockfile fix --- yarn.lock | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/yarn.lock b/yarn.lock index 3e4f8d14e..3c291a6c3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -18566,6 +18566,13 @@ __metadata: languageName: node linkType: hard +"jose@npm:6.2.3": + version: 6.2.3 + resolution: "jose@npm:6.2.3" + checksum: 10c0/aa91bccba22cc84d86308f833749bcb0b00441e35c24e0ac79abeac5f76dc62d47bdef9c1da6a0c609f5da6478595f52b252085888b89dbdb163861e40ea4188 + languageName: node + linkType: hard + "jose@npm:6.2.4, jose@npm:^6.0.8, jose@npm:^6.2.2": version: 6.2.4 resolution: "jose@npm:6.2.4" From a72c0aab96e8b6186685d6285f9d5f57fe0f6e10 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 30 Jul 2026 08:27:53 +0000 Subject: [PATCH 37/38] chore(dependencies): updated changesets for modified dependencies --- .changeset/@accounter_client-3941-dependencies.md | 5 ----- .../@accounter_israeli-vat-scraper-3941-dependencies.md | 5 ----- .../@accounter_modern-poalim-scraper-3941-dependencies.md | 5 ----- .changeset/@accounter_scraper-app-3941-dependencies.md | 5 ----- .changeset/@accounter_server-3941-dependencies.md | 6 ------ 5 files changed, 26 deletions(-) delete mode 100644 .changeset/@accounter_client-3941-dependencies.md delete mode 100644 .changeset/@accounter_israeli-vat-scraper-3941-dependencies.md delete mode 100644 .changeset/@accounter_modern-poalim-scraper-3941-dependencies.md delete mode 100644 .changeset/@accounter_scraper-app-3941-dependencies.md delete mode 100644 .changeset/@accounter_server-3941-dependencies.md diff --git a/.changeset/@accounter_client-3941-dependencies.md b/.changeset/@accounter_client-3941-dependencies.md deleted file mode 100644 index 3e97b4f17..000000000 --- a/.changeset/@accounter_client-3941-dependencies.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@accounter/client": patch ---- -dependencies updates: - - Updated dependency [`react-hook-form@7.83.0` ↗︎](https://www.npmjs.com/package/react-hook-form/v/7.83.0) (from `7.82.0`, in `dependencies`) diff --git a/.changeset/@accounter_israeli-vat-scraper-3941-dependencies.md b/.changeset/@accounter_israeli-vat-scraper-3941-dependencies.md deleted file mode 100644 index d3081bb4d..000000000 --- a/.changeset/@accounter_israeli-vat-scraper-3941-dependencies.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@accounter/israeli-vat-scraper": patch ---- -dependencies updates: - - Updated dependency [`puppeteer@25.4.0` ↗︎](https://www.npmjs.com/package/puppeteer/v/25.4.0) (from `25.3.0`, in `dependencies`) diff --git a/.changeset/@accounter_modern-poalim-scraper-3941-dependencies.md b/.changeset/@accounter_modern-poalim-scraper-3941-dependencies.md deleted file mode 100644 index cf44cc17d..000000000 --- a/.changeset/@accounter_modern-poalim-scraper-3941-dependencies.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@accounter/modern-poalim-scraper": patch ---- -dependencies updates: - - Updated dependency [`puppeteer@25.4.0` ↗︎](https://www.npmjs.com/package/puppeteer/v/25.4.0) (from `25.3.0`, in `dependencies`) diff --git a/.changeset/@accounter_scraper-app-3941-dependencies.md b/.changeset/@accounter_scraper-app-3941-dependencies.md deleted file mode 100644 index 3e19e704a..000000000 --- a/.changeset/@accounter_scraper-app-3941-dependencies.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@accounter/scraper-app": patch ---- -dependencies updates: - - Updated dependency [`@fastify/static@10.1.2` ↗︎](https://www.npmjs.com/package/@fastify/static/v/10.1.2) (from `10.1.0`, in `dependencies`) diff --git a/.changeset/@accounter_server-3941-dependencies.md b/.changeset/@accounter_server-3941-dependencies.md deleted file mode 100644 index 9fff50847..000000000 --- a/.changeset/@accounter_server-3941-dependencies.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@accounter/server": patch ---- -dependencies updates: - - Updated dependency [`@opentelemetry/auto-instrumentations-node@0.79.0` ↗︎](https://www.npmjs.com/package/@opentelemetry/auto-instrumentations-node/v/0.79.0) (from `0.78.0`, in `dependencies`) - - Updated dependency [`auth0@6.1.0` ↗︎](https://www.npmjs.com/package/auth0/v/6.1.0) (from `6.0.0`, in `dependencies`) From 1feafcc6a5048aeff92b1ebf63341cc67dbd25fa Mon Sep 17 00:00:00 2001 From: Gil Gardosh Date: Thu, 30 Jul 2026 15:44:36 +0300 Subject: [PATCH 38/38] Address review comments --- packages/mcp-server/src/config/env.ts | 10 +++++----- packages/mcp-server/src/tools/policy.ts | 23 +++++++++++++---------- packages/mcp-server/src/tools/reports.ts | 21 +++++++++++++++++---- 3 files changed, 35 insertions(+), 19 deletions(-) diff --git a/packages/mcp-server/src/config/env.ts b/packages/mcp-server/src/config/env.ts index 2b7261e1c..d0034520c 100644 --- a/packages/mcp-server/src/config/env.ts +++ b/packages/mcp-server/src/config/env.ts @@ -46,10 +46,10 @@ const booleanFlag = (defaultValue: '0' | '1') => */ export const envSchema = zod.object({ // --- Required (no safe default) --- - MCP_PUBLIC_BASE_URL: zod.url({ error: 'MCP_PUBLIC_BASE_URL must be a valid URL' }), - AUTH0_ISSUER_URL: zod.url({ error: 'AUTH0_ISSUER_URL must be a valid URL' }), - AUTH0_AUDIENCE: zod.string().min(1, { error: 'AUTH0_AUDIENCE must be a non-empty string' }), - GRAPHQL_UPSTREAM_URL: zod.url({ error: 'GRAPHQL_UPSTREAM_URL must be a valid URL' }), + MCP_PUBLIC_BASE_URL: zod.url({ message: 'MCP_PUBLIC_BASE_1URL must be a valid URL' }), + AUTH0_ISSUER_URL: zod.url({ message: 'AUTH0_ISSUER_URL must be a valid URL' }), + AUTH0_AUDIENCE: zod.string().min(1, { message: 'AUTH0_AUDIENCE must be a non-empty string' }), + GRAPHQL_UPSTREAM_URL: zod.url({ message: 'GRAPHQL_UPSTREAM_URL must be a valid URL' }), // --- Optional with secure defaults --- MCP_SERVER_PORT: emptyStringAsUndefined( @@ -60,7 +60,7 @@ export const envSchema = zod.object({ // explicitly enabled. The registry enforces this in later prompts. MCP_TOOL_ALLOWLIST: emptyStringAsUndefined(zod.string().optional().default('')), AUTH0_JWKS_URL: emptyStringAsUndefined( - zod.url({ error: 'AUTH0_JWKS_URL must be a valid URL' }).optional(), + zod.url({ message: 'AUTH0_JWKS_URL must be a valid URL' }).optional(), ), GRAPHQL_UPSTREAM_TIMEOUT_MS: emptyStringAsUndefined( zod.coerce.number().int().positive().max(120_000).optional().default(10_000), diff --git a/packages/mcp-server/src/tools/policy.ts b/packages/mcp-server/src/tools/policy.ts index bf3f28301..a7098805b 100644 --- a/packages/mcp-server/src/tools/policy.ts +++ b/packages/mcp-server/src/tools/policy.ts @@ -50,25 +50,28 @@ export interface EvaluatePolicyParams { export function evaluateToolPolicy(params: EvaluatePolicyParams): PolicyDecision { const { policy, auth, requestedBusinessIds } = params; - // 1. Role gate (any-of). - if (policy.requiredRoles && policy.requiredRoles.length > 0) { - const held = new Set(auth.roles); - if (!policy.requiredRoles.some(role => held.has(role))) { - return deny('Caller is missing a required role for this tool'); - } - } - - // 2. Resolve requested scope as a subset of authorized memberships. + // 1. Resolve requested scope as a subset of authorized memberships. const readScope = resolveRequestedReadScope(auth, requestedBusinessIds); if (readScope === null) { return deny('Requested business scope is outside your authorized memberships'); } - // 3. Business-scope requirement. + // 2. Business-scope requirement. if (policy.requiresBusinessScope && readScope.businessIds.length === 0) { return deny('No authorized business scope for this request'); } + // 3. Role gate (any-of) evaluated against membership roleIds in the resolved scope. + if (policy.requiredRoles && policy.requiredRoles.length > 0) { + const inScope = new Set(readScope.businessIds); + const held = new Set( + auth.memberships.filter(m => inScope.has(m.businessId)).map(m => m.roleId), + ); + if (!policy.requiredRoles.some(role => held.has(role))) { + return deny('Caller is missing a required role for this tool'); + } + } + return { allowed: true, readScope }; } diff --git a/packages/mcp-server/src/tools/reports.ts b/packages/mcp-server/src/tools/reports.ts index 5074b9bd9..459436a74 100644 --- a/packages/mcp-server/src/tools/reports.ts +++ b/packages/mcp-server/src/tools/reports.ts @@ -64,15 +64,28 @@ interface RawBalanceRow { const DAY_MS = 24 * 60 * 60 * 1000; function assertDateRange(input: BalanceReportInput): void { - const from = Date.parse(input.fromDate); - const to = Date.parse(input.toDate); - if (Number.isNaN(from) || Number.isNaN(to)) { + const parseCalendarDate = (value: string): number | null => { + const [year, month, day] = value.split('-').map(Number); + const timestamp = Date.UTC(year, month - 1, day); + const date = new Date(timestamp); + if ( + date.getUTCFullYear() !== year || + date.getUTCMonth() !== month - 1 || + date.getUTCDate() !== day + ) { + return null; + } + return timestamp; + }; + const from = parseCalendarDate(input.fromDate); + const to = parseCalendarDate(input.toDate); + if (from === null || to === null) { throw new ToolInputError('Invalid fromDate/toDate'); } if (from > to) { throw new ToolInputError('fromDate must be on or before toDate'); } - if ((to - from) / DAY_MS > MAX_REPORT_DATE_RANGE_DAYS) { + if (Math.round((to - from) / DAY_MS) > MAX_REPORT_DATE_RANGE_DAYS) { throw new ToolInputError(`Date range must not exceed ${MAX_REPORT_DATE_RANGE_DAYS} days`); } }