diff --git a/docs/mcp/implementation-blueprint.md b/docs/mcp/implementation-blueprint.md new file mode 100644 index 0000000000..2092d55990 --- /dev/null +++ b/docs/mcp/implementation-blueprint.md @@ -0,0 +1,922 @@ +# Accounter MCP Implementation Blueprint and Incremental Prompt Pack + +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 + +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. Resolve business memberships from the server (forward the caller's token) and map identity + + memberships into the internal auth context. + +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-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 + +### 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. 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. +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 (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. + +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 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: +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 (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. +``` + +## 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 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 upstream header propagation (correlation id and Authorization). +``` + +## 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/operations-runbook.md b/docs/mcp/operations-runbook.md new file mode 100644 index 0000000000..146ad9b668 --- /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/spec.md b/docs/mcp/spec.md new file mode 100644 index 0000000000..89e24f245d --- /dev/null +++ b/docs/mcp/spec.md @@ -0,0 +1,554 @@ +# 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 + +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). + +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: + +- 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 + +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: + +- 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 a subset of the authorized +memberships resolved from the server (§7.1). + +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 (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. + +## 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 token-identity-to-user mapping and server-resolved memberships against the 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_DOMAIN +- AUTH0_AUDIENCE +- 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 +- 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. diff --git a/docs/mcp/submission-checklist.md b/docs/mcp/submission-checklist.md new file mode 100644 index 0000000000..ed3cc7929c --- /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/.gitignore b/packages/mcp-server/.gitignore new file mode 100644 index 0000000000..ffef9a643d --- /dev/null +++ b/packages/mcp-server/.gitignore @@ -0,0 +1,2 @@ +# Generated benchmark artifacts (Prompt 22) +bench/ diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md new file mode 100644 index 0000000000..9f4c832e86 --- /dev/null +++ b/packages/mcp-server/README.md @@ -0,0 +1,259 @@ +# @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 +- [`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 + +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 + +`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` +/ `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 + (`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. +- **`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 + +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 + +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 +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 + +`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 + +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 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 + +```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). + +### MCP endpoint + +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` +(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' \ + -H 'Authorization: Bearer ' \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' +``` + +## 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 + +```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 +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. diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json new file mode 100644 index 0000000000..88156e5e71 --- /dev/null +++ b/packages/mcp-server/package.json @@ -0,0 +1,53 @@ +{ + "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": { + "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", + "start": "node dist/index.js", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "dotenv": "17.4.2", + "jose": "6.2.3", + "zod": "4.4.3" + }, + "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__/context.test.ts b/packages/mcp-server/src/__tests__/context.test.ts new file mode 100644 index 0000000000..7ee12b715a --- /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__/index.test.ts b/packages/mcp-server/src/__tests__/index.test.ts new file mode 100644 index 0000000000..4d395565df --- /dev/null +++ b/packages/mcp-server/src/__tests__/index.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { installProcessErrorHandlers, main, PACKAGE_NAME, start } from '../index.js'; + +describe('mcp-server entrypoint', () => { + it('exposes the package name', () => { + expect(PACKAGE_NAME).toBe('@accounter/mcp-server'); + }); + + 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 0000000000..7dee3fc635 --- /dev/null +++ b/packages/mcp-server/src/__tests__/logger.test.ts @@ -0,0 +1,88 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +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(() => { + 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'); + }); +}); + +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__/mcp-e2e.test.ts b/packages/mcp-server/src/__tests__/mcp-e2e.test.ts new file mode 100644 index 0000000000..f140abc594 --- /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); + }); +}); 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 0000000000..78b8fe1311 --- /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}`, + ); +}); 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 0000000000..1680bd2aca --- /dev/null +++ b/packages/mcp-server/src/__tests__/server.test.ts @@ -0,0 +1,243 @@ +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(() => {}); +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(), + once: vi.fn(), + statusCode: 200, + headersSent: false, + } as unknown as ServerResponse & { + writeHead: ReturnType; + end: ReturnType; + setHeader: ReturnType; + once: 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('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('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(); + 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('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); + 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/auth/__tests__/identity.test.ts b/packages/mcp-server/src/auth/__tests__/identity.test.ts new file mode 100644 index 0000000000..ac0122e921 --- /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/__tests__/token.test.ts b/packages/mcp-server/src/auth/__tests__/token.test.ts new file mode 100644 index 0000000000..c7387fea09 --- /dev/null +++ b/packages/mcp-server/src/auth/__tests__/token.test.ts @@ -0,0 +1,188 @@ +import type { IncomingMessage } from 'node:http'; +import { generateKeyPair, type JWTVerifyGetKey, type KeyObject, SignJWT } from 'jose'; +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/'; +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); + }); + + 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/auth/identity.ts b/packages/mcp-server/src/auth/identity.ts new file mode 100644 index 0000000000..de5b5e1a4f --- /dev/null +++ b/packages/mcp-server/src/auth/identity.ts @@ -0,0 +1,230 @@ +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; + +/** + * 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; + } + 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/auth/token.ts b/packages/mcp-server/src/auth/token.ts new file mode 100644 index 0000000000..b95e0dc266 --- /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 0000000000..036d4e9ad9 --- /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/config/__tests__/env.test.ts b/packages/mcp-server/src/config/__tests__/env.test.ts new file mode 100644 index 0000000000..6cb08b33c5 --- /dev/null +++ b/packages/mcp-server/src/config/__tests__/env.test.ts @@ -0,0 +1,154 @@ +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', + 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); + }); +}); + +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/config/env.ts b/packages/mcp-server/src/config/env.ts new file mode 100644 index 0000000000..d0034520c3 --- /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({ 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( + 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({ 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), + ), + 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/packages/mcp-server/src/context.ts b/packages/mcp-server/src/context.ts new file mode 100644 index 0000000000..77898be927 --- /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/errors/__tests__/taxonomy.test.ts b/packages/mcp-server/src/errors/__tests__/taxonomy.test.ts new file mode 100644 index 0000000000..7fe7cd5959 --- /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 0000000000..3056a59c11 --- /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/index.ts b/packages/mcp-server/src/index.ts new file mode 100644 index 0000000000..1b28bd3599 --- /dev/null +++ b/packages/mcp-server/src/index.ts @@ -0,0 +1,53 @@ +/** + * 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 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 { pathToFileURL } 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'; + +/** + * 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 { + 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 +// imported by tests or other modules. Compare both sides as file URLs: convert +// argv[1] with pathToFileURL so the check is robust even when the entry point is +// reached through a bin symlink (where argv[1] and import.meta.url differ). +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(); +} diff --git a/packages/mcp-server/src/logger.ts b/packages/mcp-server/src/logger.ts new file mode 100644 index 0000000000..2bd21f85d7 --- /dev/null +++ b/packages/mcp-server/src/logger.ts @@ -0,0 +1,86 @@ +/* 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. 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), + }), + ); + } +} + +/** Structured fields derived from a request context (no secrets/headers). */ +export function contextFields(context: RequestContext): 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/mcp/__tests__/handler.test.ts b/packages/mcp-server/src/mcp/__tests__/handler.test.ts new file mode 100644 index 0000000000..a757e42174 --- /dev/null +++ b/packages/mcp-server/src/mcp/__tests__/handler.test.ts @@ -0,0 +1,291 @@ +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 { 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'; + +// 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); + +// 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/', + 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 }) }); +} + +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 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); + 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, + 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() { + 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', () => { + 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); + + 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' }); + }); + + 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"', + ); + 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('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. + 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('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'); + 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/__tests__/jsonrpc.test.ts b/packages/mcp-server/src/mcp/__tests__/jsonrpc.test.ts new file mode 100644 index 0000000000..f2bf9feecf --- /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 0000000000..d8f0660cd5 --- /dev/null +++ b/packages/mcp-server/src/mcp/handler.ts @@ -0,0 +1,363 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { + getAuthContext, + IdentityMappingError, + resolveAuthContext, + setAuthContext, + type McpAuthContext, +} from '../auth/identity.js'; +import { + extractBearerToken, + setAuthPrincipal, + TokenVerificationError, + type AuthPrincipal, +} from '../auth/token.js'; +import { verifyAccessToken } from '../auth/verifier.js'; +import { env } from '../config/env.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 { 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'; +import { getUpstreamClient } from '../upstream/default-client.js'; +import { createUpstreamMembershipSource } from '../upstream/memberships.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. + * 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. */ +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': { + // 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)); + } + // 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}`); + } +} + +/** 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 { + 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 { + response: failure(null, JsonRpcErrorCode.InvalidRequest, 'Batch requests are not supported'), + }; + } + + const request = asJsonRpcRequest(parsed); + if (!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(), + limiter: getRateLimiter(), + metrics: getMetrics(), + }); + 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; + 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)); +} + +/** + * Whether the request carries a bearer token in the Authorization header. + * 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 { + return extractBearerToken(req) !== null; +} + +/** + * 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. + */ +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), + }); + return null; + } + + try { + 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 + // 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 + // 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; + } + getMetrics().recordAuthFailure('invalid_token'); + // 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; + } + + 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 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. + 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 0000000000..bf724aa660 --- /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 0000000000..6861e28bbf --- /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/oauth/__tests__/challenge.test.ts b/packages/mcp-server/src/oauth/__tests__/challenge.test.ts new file mode 100644 index 0000000000..223679d35e --- /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/__tests__/metadata.test.ts b/packages/mcp-server/src/oauth/__tests__/metadata.test.ts new file mode 100644 index 0000000000..38bdf078f5 --- /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/challenge.ts b/packages/mcp-server/src/oauth/challenge.ts new file mode 100644 index 0000000000..63e0b5c50d --- /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', + }), + ); +} diff --git a/packages/mcp-server/src/oauth/metadata.ts b/packages/mcp-server/src/oauth/metadata.ts new file mode 100644 index 0000000000..2cd568eaae --- /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/observability/__tests__/metrics.test.ts b/packages/mcp-server/src/observability/__tests__/metrics.test.ts new file mode 100644 index 0000000000..0e95af5d1f --- /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 0000000000..513155a54e --- /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 0000000000..2ceca72495 --- /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 0000000000..0e67e699be --- /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/rate-limit/__tests__/limiter.test.ts b/packages/mcp-server/src/rate-limit/__tests__/limiter.test.ts new file mode 100644 index 0000000000..236bc1f39c --- /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 0000000000..cedae898e2 --- /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 0000000000..0de2e05859 --- /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/server.ts b/packages/mcp-server/src/server.ts new file mode 100644 index 0000000000..86001609c3 --- /dev/null +++ b/packages/mcp-server/src/server.ts @@ -0,0 +1,193 @@ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; +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 { getMetrics } from './observability/metrics.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, the MCP + * transport route (`POST /mcp`), and graceful shutdown. Kept dependency-free + * (stdlib only) to avoid runtime framework lock-in. + */ + +export { getServiceVersion, SERVICE_NAME } from './version.js'; + +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 MCP_ROUTE_PATH = '/mcp'; + +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); + }, + // 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) => { + 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) => { + res.writeHead(405, { 'Content-Type': 'application/json', Allow: 'POST' }); + res.end(JSON.stringify({ error: 'Method Not Allowed' })); + }, + }, + POST: { + [MCP_ROUTE_PATH]: mcpHttpHandler, + }, +}; + +export async function requestHandler(req: IncomingMessage, res: ServerResponse): Promise { + 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 { + // 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 { + sendJson(res, 404, { error: 'Not found' }); + } + } catch (error) { + logger.error('request failed', { + error: String(error), + latencyMs: elapsedMs(context), + }); + 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; +} 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 0000000000..6d0722fa4f --- /dev/null +++ b/packages/mcp-server/src/tools/__tests__/charges.test.ts @@ -0,0 +1,233 @@ +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: { hasNextPage: boolean }; + totalCount: number; + truncated: boolean; + }; + expect(structured.charges).toEqual([ + { + id: 'c1', + description: 'Coffee', + amount: { value: 12.5, formatted: '₪12.50', currency: 'ILS' }, + date: '2026-01-05', + }, + ]); + expect(structured.totalCount).toBe(1); + expect(structured.truncated).toBe(false); + 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 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 }); + 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/__tests__/lookups.test.ts b/packages/mcp-server/src/tools/__tests__/lookups.test.ts new file mode 100644 index 0000000000..1f3593b52d --- /dev/null +++ b/packages/mcp-server/src/tools/__tests__/lookups.test.ts @@ -0,0 +1,141 @@ +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 }>; + totalCount: number; + }; + expect(structured.tags.map(t => t.name)).toEqual(['Banana']); + 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[]; + totalCount: number; + truncated: boolean; + continuation: { reason: string }; + }; + expect(structured.tags).toHaveLength(2); + 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 () => { + 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/__tests__/metrics.execute.test.ts b/packages/mcp-server/src/tools/__tests__/metrics.execute.test.ts new file mode 100644 index 0000000000..74a40b2d6e --- /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/__tests__/output.test.ts b/packages/mcp-server/src/tools/__tests__/output.test.ts new file mode 100644 index 0000000000..8221682f15 --- /dev/null +++ b/packages/mcp-server/src/tools/__tests__/output.test.ts @@ -0,0 +1,111 @@ +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 — 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 }); + 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__/policy.test.ts b/packages/mcp-server/src/tools/__tests__/policy.test.ts new file mode 100644 index 0000000000..9a032f27e3 --- /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/__tests__/rate-limit.execute.test.ts b/packages/mcp-server/src/tools/__tests__/rate-limit.execute.test.ts new file mode 100644 index 0000000000..72cfccc932 --- /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/__tests__/registry.test.ts b/packages/mcp-server/src/tools/__tests__/registry.test.ts new file mode 100644 index 0000000000..793cb7b00c --- /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/__tests__/reports.test.ts b/packages/mcp-server/src/tools/__tests__/reports.test.ts new file mode 100644 index 0000000000..b87501da80 --- /dev/null +++ b/packages/mcp-server/src/tools/__tests__/reports.test.ts @@ -0,0 +1,175 @@ +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[]; + totalCount: number; + businessId: string; + }; + expect(structured.businessId).toBe('b1'); + expect(structured.totalCount).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'); + }); + + 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', () => { + 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[]; + 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); + }); +}); + +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/charges.ts b/packages/mcp-server/src/tools/charges.ts new file mode 100644 index 0000000000..6444d7916a --- /dev/null +++ b/packages/mcp-server/src/tools/charges.ts @@ -0,0 +1,222 @@ +import { z } from 'zod'; +import { ToolInputError } from './execute.js'; +import { shapeListResult } from './output.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, + hasNextPage: (pageInfo.currentPage ?? input.page) < pageInfo.totalPages, + }; + + 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 = { + 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 0000000000..a723a4259b --- /dev/null +++ b/packages/mcp-server/src/tools/execute.ts @@ -0,0 +1,189 @@ +import type { McpAuthContext } from '../auth/identity.js'; +import { + errorPayload, + 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'; +import { + validateToolInput, + type ToolDefinition, + type ToolExecutionContext, + type ToolResult, +} from './registry.js'; + +/** + * Curated tool execution: input validation → authorization policy → handler. + * + * 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. + */ + +// 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 + * `businessIds` array or a singular `businessId` string is honored. + */ +function requestedBusinessIds(input: unknown): string[] | undefined { + 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; +} + +export interface ExecuteToolParams { + tool: ToolDefinition; + rawArgs: unknown; + auth: McpAuthContext; + correlationId: string; + client: UpstreamGraphQLClient; + 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. 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). + const validation = validateToolInput(tool, rawArgs); + if (!validation.ok) { + return toToolErrorResult( + errorPayload('VALIDATION_ERROR', 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 toToolErrorResult( + errorPayload('AUTHORIZATION_ERROR', decision.error.message, correlationId), + ); + } + + // 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, + correlationId, + client, + authorization, + }; + try { + // 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. + if (isInternalError(error)) { + 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 toToolErrorResult(toErrorPayload(error, correlationId)); + } +} diff --git a/packages/mcp-server/src/tools/lookups.ts b/packages/mcp-server/src/tools/lookups.ts new file mode 100644 index 0000000000..aa7f20f84a --- /dev/null +++ b/packages/mcp-server/src/tools/lookups.ts @@ -0,0 +1,186 @@ +import { z } from 'zod'; +import { shapeListResult } from './output.js'; +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 filterSortCap( + rows: T[], + search: string | undefined, + max: number, +): { 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); + // `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 }; +} + +// --------------------------------------------------------------------------- +// 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 } = filterSortCap(data.allTags, input.nameContains, input.limit); + const tags = rows.map(tag => ({ + id: tag.id, + name: tag.name, + namePath: tag.namePath ?? [tag.name], + })); + + return shapeListResult({ + items: tags, + itemsKey: 'tags', + total, + summarize: (_shown, count, truncated) => + `Found ${count} ${count === 1 ? 'tag' : 'tags'}${truncated ? ' (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 } = filterSortCap( + activeFiltered, + input.nameContains, + input.limit, + ); + + return shapeListResult({ + items: taxCategories, + itemsKey: 'taxCategories', + total, + summarize: (_shown, count, truncated) => + `Found ${count} tax ${count === 1 ? 'category' : 'categories'}${ + truncated ? ' (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/output.ts b/packages/mcp-server/src/tools/output.ts new file mode 100644 index 0000000000..d65b4ee623 --- /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/policy.ts b/packages/mcp-server/src/tools/policy.ts new file mode 100644 index 0000000000..a7098805b5 --- /dev/null +++ b/packages/mcp-server/src/tools/policy.ts @@ -0,0 +1,85 @@ +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. 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'); + } + + // 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 }; +} + +/** 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 }); +} 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 0000000000..c9aeb88aa7 --- /dev/null +++ b/packages/mcp-server/src/tools/registry-instance.ts @@ -0,0 +1,15 @@ +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 + * they are added; the MCP transport lists and dispatches from this instance. + */ +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/registry.ts b/packages/mcp-server/src/tools/registry.ts new file mode 100644 index 0000000000..b125bccda2 --- /dev/null +++ b/packages/mcp-server/src/tools/registry.ts @@ -0,0 +1,177 @@ +import { z } from 'zod'; +import type { AuthorizedReadScope, McpAuthContext } from '../auth/identity.js'; +import type { UpstreamGraphQLClient } from '../upstream/graphql-client.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; + /** Shared upstream GraphQL client for read-only operations. */ + client: UpstreamGraphQLClient; + /** Caller's Authorization header, forwarded upstream (never logged). */ + authorization?: 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, + })); + } +} diff --git a/packages/mcp-server/src/tools/reports.ts b/packages/mcp-server/src/tools/reports.ts new file mode 100644 index 0000000000..459436a746 --- /dev/null +++ b/packages/mcp-server/src/tools/reports.ts @@ -0,0 +1,157 @@ +import { z } from 'zod'; +import { ToolInputError } from './execute.js'; +import { shapeListResult } from './output.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 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 (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`); + } +} + +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 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 shapeListResult({ + items: rows, + itemsKey: 'rows', + total: all.length, + extra: { + reportType: input.reportType, + businessId: ownerId, + period: { fromDate: input.fromDate, toDate: input.toDate }, + }, + summarize: (shown, total) => + `Balance report for ${input.fromDate}–${input.toDate}: ${total} transaction(s)${ + shown < total ? ` (showing ${shown})` : '' + }.`, + }); +} + +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, +}; 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 0000000000..62a8867eed --- /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/__tests__/memberships.test.ts b/packages/mcp-server/src/upstream/__tests__/memberships.test.ts new file mode 100644 index 0000000000..595f9a8845 --- /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/default-client.ts b/packages/mcp-server/src/upstream/default-client.ts new file mode 100644 index 0000000000..f53d92ece6 --- /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 0000000000..71049be184 --- /dev/null +++ b/packages/mcp-server/src/upstream/graphql-client.ts @@ -0,0 +1,243 @@ +/** + * 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). + */ + +import { withSpan } from '../observability/tracing.js'; + +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); + + // 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; + } + } + }); + } + + 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); + }; +} diff --git a/packages/mcp-server/src/upstream/memberships.ts b/packages/mcp-server/src/upstream/memberships.ts new file mode 100644 index 0000000000..ed1c963713 --- /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 }); +} diff --git a/packages/mcp-server/src/version.ts b/packages/mcp-server/src/version.ts new file mode 100644 index 0000000000..f9969ff1f6 --- /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; +} diff --git a/packages/mcp-server/tsconfig.json b/packages/mcp-server/tsconfig.json new file mode 100644 index 0000000000..e306bfcac3 --- /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 0000000000..0a2995a8d3 --- /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/packages/server/src/modules/auth/__tests__/memberships.test.ts b/packages/server/src/modules/auth/__tests__/memberships.test.ts new file mode 100644 index 0000000000..d47f27451c --- /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 439b3092e9..3acfe568de 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 0000000000..0af938e732 --- /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 5abe5057c5..45f33d86c8 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 { diff --git a/yarn.lock b/yarn.lock index f23252ab2e..3c291a6c3e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -264,6 +264,20 @@ __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" + 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" + zod: "npm:4.4.3" + 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" @@ -18552,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"