diff --git a/docs/mcp/implementation-blueprint.md b/docs/mcp/implementation-blueprint.md new file mode 100644 index 000000000..39152a21f --- /dev/null +++ b/docs/mcp/implementation-blueprint.md @@ -0,0 +1,829 @@ +# Accounter MCP Implementation Blueprint and Incremental Prompt Pack + +Status: Draft for execution Related spec: docs/mcp/spec.md Last updated: 2026-07-15 + +## 1) How to use this document + +This is an execution plan for building the MCP connector defined in docs/mcp/spec.md. + +It is intentionally iterative: + +1. Start with a full end-to-end blueprint. +2. Decompose into implementation chunks. +3. Decompose again into right-sized steps. +4. Use the prompt pack to drive a code-generation LLM through safe, incremental implementation. + +Each prompt is designed to build on the previous prompt with no orphaned code. + +## 2) Foundation constraints + +- Phase 1 target: hosted Claude surfaces (Claude.ai/Desktop) +- Auth stack: Auth0 OAuth, CIMD-first +- Scope: read-only tools only +- Package boundary: new package at packages/mcp-server +- Metadata hosting: same domain plus explicit 401 resource_metadata pointer +- Authorization source of truth: existing server auth and business membership model +- No generic GraphQL passthrough tool + +## 3) End-to-end blueprint (single-pass view) + +## 3.1 Workstream A: Runtime and package foundation + +1. Create packages/mcp-server package scaffold. +2. Add TypeScript config, linting, tests, and scripts with yarn workspace conventions. +3. Build HTTP server entrypoint with graceful shutdown and health route. +4. Add configuration loader with strict environment validation. + +Exit condition: + +- Service starts in local/dev mode, health endpoint works, tests run. + +## 3.2 Workstream B: MCP transport and protocol skeleton + +1. Implement Streamable HTTP MCP endpoint. +2. Implement protocol handshake and a minimal tool listing path. +3. Add request/response context with correlation id. +4. Add deterministic stub tool for smoke testing. + +Exit condition: + +- MCP endpoint is reachable and can list at least one test tool. + +## 3.3 Workstream C: OAuth discovery and authentication + +1. Serve protected resource metadata on well-known path. +2. Add standards-compliant 401 challenge with WWW-Authenticate and resource_metadata pointer. +3. Implement bearer token validator using Auth0 issuer/audience/JWKS. +4. Add claim mapping into internal auth identity model. + +Exit condition: + +- Unauthenticated calls get correct challenge. +- Valid token establishes authenticated request context. + +## 3.4 Workstream D: Authorization and tool-policy enforcement + +1. Build tool policy model (roles, scope, redaction level). +2. Enforce business membership and scope narrowing constraints. +3. Add per-tool auth checks before execution. +4. Add explicit deny behavior for unauthorized scope. + +Exit condition: + +- Cross-tenant and unauthorized role requests are rejected deterministically. + +## 3.5 Workstream E: Upstream GraphQL orchestration + +1. Build GraphQL client abstraction with timeout, retry rules, and error translation. +2. Add typed operation wrappers for phase 1 read-only operations. +3. Normalize output shape and pagination behavior. +4. Add output limits and truncation continuation hints. + +Exit condition: + +- At least two production tools return bounded, safe results. + +## 3.6 Workstream F: Production hardening + +1. Implement full error taxonomy and mapping. +2. Add structured logging and metrics. +3. Add optional tracing spans and correlation propagation. +4. Add rate limiting by user, business, tool. + +Exit condition: + +- Failure modes are observable and controlled. + +## 3.7 Workstream G: Testing, release, and directory readiness + +1. Unit tests for auth, policy, schema validation, and mappers. +2. Integration tests for OAuth challenge flow and tool execution. +3. Security tests for tenant isolation and malformed input. +4. Performance checks for latency and timeout budgets. +5. Submission readiness checklist and ops runbook. + +Exit condition: + +- Connector can be validated for directory submission with predictable behavior. + +## 4) Decomposition round 1: delivery chunks (epic-sized) + +This first decomposition groups work into medium chunks that can be assigned to one engineer per 1-3 +days. + +### Chunk R1-1: Package setup and runtime skeleton + +- Includes workstreams A and part of B +- Estimated size: 1.5-2 days +- Risk: low + +### Chunk R1-2: MCP protocol baseline + +- Remaining B +- Estimated size: 1-1.5 days +- Risk: low + +### Chunk R1-3: OAuth discovery and token validation + +- Workstream C +- Estimated size: 2-3 days +- Risk: medium + +### Chunk R1-4: Policy enforcement layer + +- Workstream D +- Estimated size: 1.5-2 days +- Risk: medium + +### Chunk R1-5: First toolset integration + +- Workstream E +- Estimated size: 2-3 days +- Risk: medium + +### Chunk R1-6: Hardening and observability + +- Workstream F +- Estimated size: 2 days +- Risk: medium + +### Chunk R1-7: Test matrix and release prep + +- Workstream G +- Estimated size: 2-3 days +- Risk: medium + +Assessment: good strategic grouping, but still too large for safe LLM code generation in one prompt +per chunk. + +## 5) Decomposition round 2: implementation slices (story-sized) + +This decomposition splits each epic into slices that can usually be done in a single focused PR. + +### Slice S2-1: package scaffold and scripts + +### Slice S2-2: env schema and config accessors + +### Slice S2-3: HTTP server bootstrap and health route + +### Slice S2-4: MCP endpoint shell and list-tools stub + +### Slice S2-5: request context and correlation ids + +### Slice S2-6: well-known metadata route + +### Slice S2-7: 401 challenge middleware + +### Slice S2-8: Auth0 token verifier module + +### Slice S2-9: identity mapping and auth context + +### Slice S2-10: tool registry abstraction and schema contracts + +### Slice S2-11: authorization policy checks per tool + +### Slice S2-12: GraphQL client with timeout/retry guardrails + +### Slice S2-13: first production tool (charges query) + +### Slice S2-14: second production tool (tags/tax categories) + +### Slice S2-15: third production tool (selected report read) + +### Slice S2-16: output shaping, limits, truncation helpers + +### Slice S2-17: error taxonomy and mapper integration + +### Slice S2-18: rate limiting middleware + +### Slice S2-19: structured logging and metrics + +### Slice S2-20: integration and security tests + +### Slice S2-21: directory readiness docs and runbook + +Assessment: much better, but still some slices mix too many moving parts (for example S2-12, S2-20). + +## 6) Decomposition round 3: right-sized steps (final execution sequence) + +This is the final, implementation-safe sequence. Each step is intended to be small enough for +low-risk implementation and review while still moving the project forward. + +## 6.1 Final step list + +1. Create package skeleton at packages/mcp-server with package.json, tsconfig, src/index.ts, and + basic yarn scripts. +2. Add local lint/test/build scripts and wire into workspace without affecting existing packages. +3. Add env schema module and fail-fast startup validation. +4. Add minimal HTTP server bootstrap and /health route. +5. Add graceful shutdown and error-safe process handlers. +6. Add MCP route shell with request parsing and placeholder response. +7. Add MCP list-tools response with one internal smoke tool. +8. Add request context utility with request id and correlation id propagation. +9. Add structured logger wrapper and request logging middleware. +10. Add protected resource metadata route under well-known path. +11. Add universal unauthenticated response helper returning 401 + WWW-Authenticate resource_metadata + pointer. +12. Add bearer token extraction and validation middleware boundary. +13. Implement Auth0 JWKS token verification module. +14. Map token claims to internal authenticated user context. +15. Add auth guard to MCP tool execution path. +16. Implement tool registry type contracts (name, schema, auth policy, handler). +17. Implement input schema validator adapter for tool invocations. +18. Implement authorization policy evaluator (role + business scope checks). +19. Implement GraphQL upstream client with timeout and non-destructive retry rules. +20. Implement upstream error classifier and safe message sanitizer. +21. Implement first tool: read-only charges search/browse with bounded pagination. +22. Implement second tool: tag and tax-category lookup with bounded output. +23. Implement third tool: selected report read operation with strict input bounds. +24. Implement output shaping helpers and partial-result continuation token format. +25. Add tool-level output size guardrails and truncation behavior. +26. Add final error taxonomy mapper across transport/auth/tool/upstream errors. +27. Add per-user/per-business/per-tool rate limiting middleware. +28. Add metrics counters and latency histograms. +29. Add trace hooks and propagation to upstream GraphQL calls. +30. Add unit tests for config/auth/policy/error/output helpers. +31. Add integration tests for 401 challenge, auth success, and tool invocation flow. +32. Add security tests for cross-tenant denial and malformed input handling. +33. Add performance test for latency and timeout behavior under read workloads. +34. Add connector runbook, deployment notes, and submission readiness checklist. +35. Add end-to-end local validation script and final wiring checklist. + +## 6.2 Why this is right-sized + +- Most steps fit in one focused PR. +- Each step has clear preconditions from previous steps. +- No step introduces more than one architectural concern at once. +- Tool implementation starts only after auth, policy, and upstream client foundations exist. +- Observability and tests are integrated before final release prep. + +## 7) Dependency map for safe sequencing + +- Steps 1-5 must complete before 6-9. +- Steps 10-15 must complete before any production tool steps. +- Steps 16-20 must complete before steps 21-25. +- Steps 26-29 should complete before full integration testing. +- Steps 30-35 close quality and release readiness. + +## 8) Prompt pack for code-generation LLM + +Use prompts in strict order. Do not skip ahead. Each prompt assumes previous prompts are implemented +and committed. + +--- + +## Prompt 01 - Scaffold package and scripts + +```text +You are implementing Prompt 01 of an incremental MCP build. + +Goal: +Create a new package at packages/mcp-server with minimal TypeScript scaffolding and workspace scripts. + +Requirements: +- Add package.json with build, dev, lint, test scripts. +- Add tsconfig.json aligned with monorepo TypeScript standards. +- Add src/index.ts with a no-op startup entrypoint. +- Ensure yarn workspace commands can run for this package. +- Do not modify unrelated packages. + +Constraints: +- Use yarn only. +- Keep implementation minimal and compilable. +- No runtime framework lock-in yet. + +Validation: +- yarn workspace mcp-server build passes. +- yarn workspace mcp-server test runs (can be placeholder). + +Output: +- Show exact files created/updated and why. +``` + +## Prompt 02 - Add strict env config + +```text +You are implementing Prompt 02. + +Goal: +Add strict environment validation and config access for mcp-server. + +Requirements: +- Create config/env module using schema validation. +- Include required vars for MCP host/port, auth issuer/audience/jwks, upstream GraphQL URL/timeouts. +- Fail fast on startup when env is invalid. +- Export typed config object for use by server modules. + +Constraints: +- Keep defaults secure. +- Do not embed secrets. + +Validation: +- Unit tests for valid and invalid env scenarios. +- Startup fails with clear errors when required vars are missing. + +Output: +- Include a short env variable table in comments or docs for this package. +``` + +## Prompt 03 - HTTP server bootstrap and health + +```text +You are implementing Prompt 03. + +Goal: +Stand up a minimal HTTP server with health endpoint and graceful shutdown hooks. + +Requirements: +- Add HTTP server bootstrap in src/index.ts or src/server.ts. +- Add GET /health endpoint returning service status and version. +- Add graceful shutdown for SIGINT/SIGTERM. +- Add centralized top-level error handling for startup failures. + +Constraints: +- Keep dependencies minimal. +- No MCP protocol logic yet. + +Validation: +- Server starts locally. +- /health returns 200. +- Shutdown logs and exits cleanly. +``` + +## Prompt 04 - MCP route shell and list-tools stub + +```text +You are implementing Prompt 04. + +Goal: +Add a basic MCP transport route and return a valid list-tools response with one internal smoke tool. + +Requirements: +- Add MCP endpoint path with request dispatch skeleton. +- Implement list-tools handling. +- Return one smoke tool entry (non-production tool). +- Return deterministic unsupported-method errors for unknown operations. + +Constraints: +- Keep this auth-agnostic for now. +- No upstream GraphQL calls yet. + +Validation: +- MCP inspector or local request can list tools. +- Unknown method returns stable error shape. +``` + +## Prompt 05 - Request context and structured logging foundation + +```text +You are implementing Prompt 05. + +Goal: +Introduce request context and structured logs for observability groundwork. + +Requirements: +- Add request id and correlation id generation/extraction utility. +- Propagate context through request lifecycle. +- Add structured logger wrapper with level, request id, correlation id, route/method, latency. +- Add middleware/hooks to log request start/end and failures. + +Constraints: +- Never log secrets or authorization headers. + +Validation: +- Logs include correlation_id and request_id. +- Error logs include machine-readable fields. +``` + +## Prompt 06 - Protected resource metadata route + +```text +You are implementing Prompt 06. + +Goal: +Add OAuth protected resource metadata endpoint on well-known path. + +Requirements: +- Serve /.well-known/oauth-protected-resource. +- Include resource value matching MCP public URL. +- Include authorization_servers pointing to Auth0 issuer. +- Ensure JSON content-type and stable document shape. + +Constraints: +- Keep document generation config-driven. +- No hardcoded environment-specific URLs. + +Validation: +- Route returns 200 with valid JSON. +- Resource URL exactly matches configured MCP URL. +``` + +## Prompt 07 - 401 challenge helper with resource_metadata pointer + +```text +You are implementing Prompt 07. + +Goal: +Implement standardized unauthenticated response for MCP auth discovery. + +Requirements: +- Create reusable helper for 401 responses. +- Add WWW-Authenticate Bearer header with resource_metadata URL pointer. +- Use this helper for unauthenticated MCP calls. + +Constraints: +- Must be RFC-compliant enough for connector flows. +- Do not return tool-level errors for missing auth. + +Validation: +- Unauthenticated request returns 401 and expected header. +- Header URL points to the well-known metadata route. +``` + +## Prompt 08 - Token extraction and Auth0 verification module + +```text +You are implementing Prompt 08. + +Goal: +Add bearer token extraction and Auth0 JWT verification. + +Requirements: +- Parse Authorization: Bearer header. +- Verify token using Auth0 issuer, audience, and JWKS (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 09 - Identity mapping to business scope + +```text +You are implementing Prompt 09. + +Goal: +Map verified token identity to internal user + business membership context. + +Requirements: +- Build auth context object containing user id, roles, authorized memberships, and default read scope. +- Reuse existing server-side identity/membership assumptions where possible. +- Add a clear failure mode when mapping cannot resolve a valid user. + +Constraints: +- Keep phase 1 read-only semantics. +- No write-target resolution needed yet. + +Validation: +- Unit/integration tests for user with multiple businesses and narrowed scope behavior. +``` + +## Prompt 10 - Tool registry contracts and input validation + +```text +You are implementing Prompt 10. + +Goal: +Create a production-ready tool registry abstraction. + +Requirements: +- Define tool contract type with name, description, input schema, auth policy, handler. +- Add registration API and lookup. +- Add strict input validation with unknown-field rejection. +- Add deterministic validation error payload. + +Constraints: +- Registry must support incremental tool additions. +- Keep handlers pure and testable. + +Validation: +- Unit tests for registration, duplicate names, and schema failures. +``` + +## Prompt 11 - Authorization policy evaluator + +```text +You are implementing Prompt 11. + +Goal: +Implement policy checks per tool before execution. + +Requirements: +- Evaluate required roles and business scope constraints. +- Deny requests outside authorized memberships with AUTHORIZATION_ERROR. +- Support optional caller-provided scope narrowing only if subset of authorized scope. + +Constraints: +- Policy logic must run before handler execution. +- Keep behavior deterministic and test-covered. + +Validation: +- Tests for allow, deny, and scope-subset edge cases. +``` + +## Prompt 12 - GraphQL upstream client with timeout/retry guardrails + +```text +You are implementing Prompt 12. + +Goal: +Add a shared upstream GraphQL client used by tool handlers. + +Requirements: +- Implement request function with typed query/mutation wrappers for read-only operations. +- Add timeout budget and cancellation. +- Retry only idempotent read failures (bounded attempts, no auth/validation retries). +- Propagate correlation id 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/spec.md b/docs/mcp/spec.md new file mode 100644 index 000000000..a63efdf31 --- /dev/null +++ b/docs/mcp/spec.md @@ -0,0 +1,537 @@ +# Accounter MCP Connector Specification + +Status: Proposed Owner: Platform / Server Team Last Updated: 2026-07-15 + +## 1) Purpose + +Expose a safe, production-grade subset of Accounter server capabilities through a remote MCP server +so Claude clients can interact with business data using approved tools. + +This document is implementation-ready and defines: + +- Scope and rollout phases +- Authentication and authorization architecture +- Tool exposure strategy and data handling rules +- Error handling and observability requirements +- Testing and release criteria + +## 2) Confirmed Decisions + +These decisions were explicitly selected and are treated as baseline requirements: + +- Client surface for phase 1: Claude hosted surfaces (Claude.ai/Desktop) +- Authentication baseline: Auth0-based OAuth using existing identity model +- OAuth connector variant: CIMD preferred +- Initial capability scope: read-only tools only +- Deployment model: new monorepo package for MCP server +- Connector goal: directory-ready in near term +- Metadata hosting: same domain as MCP server with well-known routes +- Identity mapping: reuse existing Auth0 users and business memberships + +## 3) Goals and Non-Goals + +### 3.1 Goals + +- Deliver a secure MCP endpoint with OAuth-based user consent. +- Reuse existing multi-tenant authorization model from server. +- Expose high-value read-only capabilities first. +- Provide strict tenant isolation and robust auditability. +- Be compatible with connector directory requirements. + +### 3.2 Non-Goals (Phase 1) + +- No destructive or financial write operations. +- No bypass of existing role/business membership checks. +- No parallel REST API creation. +- No broad GraphQL passthrough tool. + +## 4) Existing Platform Facts to Reuse + +- GraphQL server is the canonical business API surface. +- Multi-tenant scoping exists via business memberships and read/write scope helpers. +- API key and auth providers already exist, but OAuth user auth is preferred for hosted connector + UX. +- Server architecture already uses operation-scoped providers and DI. +- Existing gateway package pattern can be reused for isolated protocol adapters. + +## 5) High-Level Architecture + +## 5.1 Package and Runtime Layout + +Create a new package: + +- packages/mcp-server + +Primary components: + +- HTTP transport layer implementing remote MCP server (Streamable HTTP) +- OAuth discovery/auth metadata endpoints +- Auth middleware (bearer token validation and identity extraction) +- Tool registry and input validation layer +- GraphQL orchestration client (calls existing server APIs) +- Output shaping/sanitization and truncation guard +- Structured logging, metrics, tracing hooks + +## 5.2 Control Flow + +1. Claude connects to MCP endpoint. +2. If unauthenticated, MCP returns 401 with WWW-Authenticate and resource metadata pointer. +3. Claude completes OAuth flow against Auth0 (CIMD path). +4. MCP validates access token. +5. Tool invocation arrives at MCP server. +6. MCP validates tool name + input schema + scope constraints. +7. MCP calls existing GraphQL operation(s). +8. MCP sanitizes/normalizes output, applies output size constraints. +9. MCP returns tool result with deterministic error mapping. + +## 5.3 Dependency Direction + +- mcp-server depends on server API contracts and shared auth utilities. +- mcp-server does not become a second source of business logic. +- Business rules remain in existing server modules/providers. + +## 6) Authentication and Metadata Hosting + +## 6.1 OAuth Mode + +Use OAuth with Auth0 and CIMD. + +Why: + +- Avoid DCR client explosion at scale. +- Better operational stability for directory traffic. +- Preserves user-consent model and per-user access control. + +## 6.2 Metadata Hosting Decision + +Primary mode: same domain as MCP server for well-known routes. + +Serve on MCP origin: + +- /.well-known/oauth-protected-resource +- (If needed by deployment) path-specific protected resource document + +Additionally, always return 401 with explicit: + +- WWW-Authenticate: Bearer + resource_metadata="https:///.well-known/oauth-protected-resource" + +This explicit pointer is required for reliability and prevents discovery failures in edge routing +scenarios. + +## 6.3 Callback and Discovery Requirements + +Auth server must support: + +- Standard OAuth discovery metadata endpoints +- PKCE S256 +- Form-url-encoded token endpoint requests +- Refresh-token flows compatible with public clients + +Hosted Claude redirect URI to allow: + +- https://claude.ai/api/mcp/auth_callback + +If Claude Code support is added later, also support loopback callback patterns as a separate phase. + +## 6.4 Token and Scope Model + +Access token claims must map to: + +- user identity +- tenant/business memberships +- role claims or resolvable roles from server + +Scope strategy: + +- Keep OAuth scopes coarse for transport access. +- Enforce fine-grained capability authorization in MCP tool authorization layer. + +## 6.5 Security Constraints + +- Never accept credentials in URL query params. +- Enforce HTTPS only. +- Reject missing/invalid token with 401 and standards-compliant headers. +- Keep auth endpoint latency comfortably under published connector limits. + +## 7) Authorization and Tenant Isolation + +## 7.1 Source of Truth + +Authorization source of truth remains existing server logic: + +- role checks +- business membership checks +- read/write scope resolution helpers + +## 7.2 Tool-Level Access Policy + +Each tool must define: + +- required role set +- allowed business scope behavior +- data-classification level +- redaction policy + +Phase 1 policy: + +- Read-only tools +- Must require authenticated user context +- Must restrict data to authorized business scope + +## 7.3 Scope Narrowing + +Support explicit scope narrowing input (when needed), but only as subset of authorized memberships. + +Rules: + +- Requested scope outside memberships => forbidden +- Empty requested scope => default authorized read scope + +## 8) Tool Exposure Strategy + +## 8.1 Selection Framework + +Expose only capabilities that pass all checks: + +- Read-only +- High utility for assistant workflows +- Deterministic and bounded response shape +- Low compliance/destructive risk +- Strong existing authorization path + +## 8.2 Initial Tool Groups (Phase 1) + +Recommended initial groups: + +- Charges browse/search (read-only) +- Tags and tax-category lookups +- Counterparty/business entity lookups +- Selected report generation queries (read-only) +- Ledger/query inspection (read-only) + +Excluded in phase 1: + +- Any merge/delete/batch destructive operation +- Ledger regeneration/locking +- Salary and payroll writes +- Provider credential operations +- Scraper or ingestion mutation surfaces + +## 8.3 Tool Contract Design + +For each tool define: + +- name +- plain-language description +- strict JSON input schema +- deterministic output schema +- authorization policy +- pagination and filtering behavior +- max result size and truncation behavior + +Avoid a generic runGraphQL tool because it bypasses curated safety controls. + +## 9) Data Handling Specification + +## 9.1 Input Validation + +- Validate all tool inputs against strict schemas. +- Reject unknown fields by default. +- Apply server-side bounds on page size, date range, and query breadth. + +## 9.2 Output Shaping + +- Return only fields needed for tool use. +- Redact or omit secrets and internal-only metadata. +- Normalize timestamps, currency formatting, and nullable fields. + +## 9.3 Size Limits and Truncation + +Respect client constraints by design: + +- Limit output payload length proactively. +- If data exceeds limits, return structured partial result with continuation hints. + +## 9.4 PII and Sensitive Data + +Define data classes: + +- Public operational metadata +- Business-sensitive financial data +- High-sensitivity identity/credential data + +Phase 1 tools must avoid high-sensitivity credential domains. + +## 9.5 Caching + +- Allow short-lived in-process cache (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 claim-to-user mapping against existing auth/user model. + +Deliverable: + +- end-to-end authenticated connection from Claude hosted surface. + +## 12.3 Phase 2: Read-Only Tool Registry + +1. Build curated tool registry abstraction. +2. Add first read-only tools from approved list. +3. Add strict input/output schemas and pagination bounds. +4. Add authorization policies per tool. + +Deliverable: + +- usable phase 1 feature set with read-only operations. + +## 12.4 Phase 3: Hardening + +1. Error taxonomy implementation and mapping. +2. Rate limiting and request budget controls. +3. Structured logs, metrics, trace propagation. +4. Payload truncation and partial-result pattern. + +Deliverable: + +- production-hardened connector behavior. + +## 12.5 Phase 4: Directory Readiness + +1. Complete security and reliability checklist. +2. Finalize connector metadata and documentation. +3. Perform submission dry-run and integration checks. + +Deliverable: + +- directory-ready connector package. + +## 13) Testing Strategy + +## 13.1 Unit Tests + +- Auth header parsing and token validation helpers +- Scope narrowing and role checks +- Tool input validation +- Error mapper behavior +- Output truncation behavior + +## 13.2 Integration Tests + +- OAuth discovery and 401 challenge flow +- Token exchange and authenticated tool calls +- GraphQL upstream success/error translation +- Cross-tenant access denial +- Pagination and filtering correctness + +## 13.3 Contract Tests + +- MCP protocol compliance for tool listing/invocation +- Well-known metadata document shape +- WWW-Authenticate header conformance + +## 13.4 Security Tests + +- Token misuse/replay scenarios +- Invalid audience/issuer claims +- Scope escalation attempts +- Injection and malformed input fuzzing +- Secret leakage checks in logs + +## 13.5 Performance and Reliability Tests + +- Tool latency under expected concurrent load +- Timeout and retry behavior under upstream slowness +- Auth endpoint response-time compliance + +## 13.6 End-to-End Acceptance Tests + +Must pass before production: + +- Claude hosted surface can connect and invoke tools +- Unauthorized business data is never returned +- All major failure modes return correct error classes +- Observability dashboards show complete request traces + +## 14) Environment and Configuration + +Define and validate env vars in package config, including: + +- MCP_SERVER_PORT +- MCP_PUBLIC_BASE_URL +- MCP_ENABLED +- MCP_TOOL_ALLOWLIST +- AUTH0_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/packages/mcp-server/README.md b/packages/mcp-server/README.md new file mode 100644 index 000000000..979d64111 --- /dev/null +++ b/packages/mcp-server/README.md @@ -0,0 +1,102 @@ +# @accounter/mcp-server + +Remote MCP (Model Context Protocol) server that exposes a curated, **read-only** subset of Accounter +capabilities to Claude clients (Claude.ai / Claude Desktop). + +See the design docs: + +- [`docs/mcp/spec.md`](../../docs/mcp/spec.md) — connector specification +- [`docs/mcp/implementation-blueprint.md`](../../docs/mcp/implementation-blueprint.md) — incremental + implementation plan + +## Status + +Early scaffolding. This package is being built incrementally following the prompt pack in the +implementation blueprint. It currently contains the package skeleton, strict environment +configuration, a minimal HTTP server with a `/health` endpoint and graceful shutdown, an MCP +transport route (`POST /mcp`) that speaks JSON-RPC 2.0 and lists an internal smoke tool, per-request +structured logging with request/correlation ids, the OAuth protected-resource metadata endpoint, and +Auth0 bearer-token verification on the MCP endpoint. Fine-grained authorization and production tools +are not implemented yet. + +## 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. Structured JSON logs are emitted at request start and completion, carrying +`requestId`, `correlationId`, `method`, `route`, and — on completion — `status` and `latencyMs`. +Secrets and authorization headers are never logged. + +## Running locally + +```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` +(for the internal `accounter_smoke_ping` tool). Unknown methods return a deterministic JSON-RPC +`-32601` error; notifications receive `202 Accepted` with no body. + +```bash +curl -sX POST http://localhost:3100/mcp -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer ' \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' +``` + +## 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 +``` diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json new file mode 100644 index 000000000..0b06ab47a --- /dev/null +++ b/packages/mcp-server/package.json @@ -0,0 +1,52 @@ +{ + "name": "@accounter/mcp-server", + "version": "0.0.0", + "type": "module", + "description": "Remote MCP (Model Context Protocol) server exposing a curated, read-only subset of Accounter capabilities to Claude clients", + "repository": { + "type": "git", + "url": "git+https://github.com/Urigo/accounter-fullstack.git", + "directory": "packages/mcp-server" + }, + "homepage": "https://github.com/Urigo/accounter-fullstack/tree/main/packages/mcp-server#readme", + "bugs": { + "url": "https://github.com/Urigo/accounter-fullstack/issues" + }, + "author": "Gil Gardosh ", + "license": "MIT", + "private": true, + "engines": { + "node": "26.5.0" + }, + "main": "dist/index.js", + "module": "dist/index.js", + "files": [ + "dist" + ], + "keywords": [ + "mcp", + "model-context-protocol", + "connector", + "accounter" + ], + "scripts": { + "build": "tsc", + "dev": "tsx watch src/index.ts", + "lint": "eslint './src/**/*.{js,ts,tsx}' --quiet", + "start": "node dist/index.js", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + }, + "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 000000000..7ee12b715 --- /dev/null +++ b/packages/mcp-server/src/__tests__/context.test.ts @@ -0,0 +1,58 @@ +import type { IncomingMessage } from 'node:http'; +import { describe, expect, it } from 'vitest'; +import { + createRequestContext, + elapsedMs, + getRequestContext, + setRequestContext, +} from '../context.js'; + +function req(overrides: Partial = {}): IncomingMessage { + return { headers: {}, method: 'GET', url: '/health', ...overrides } as unknown as IncomingMessage; +} + +describe('createRequestContext', () => { + it('mints a request id and a correlation id', () => { + const ctx = createRequestContext(req()); + expect(ctx.requestId).toMatch(/[0-9a-f-]{36}/); + expect(ctx.correlationId).toMatch(/[0-9a-f-]{36}/); + expect(ctx.requestId).not.toBe(ctx.correlationId); + }); + + it('inherits the correlation id from the inbound header', () => { + const ctx = createRequestContext(req({ headers: { 'x-correlation-id': 'trace-abc' } })); + expect(ctx.correlationId).toBe('trace-abc'); + }); + + it('captures method and route without the query string', () => { + const ctx = createRequestContext(req({ method: 'POST', url: '/mcp?token=secret' })); + expect(ctx.method).toBe('POST'); + expect(ctx.route).toBe('/mcp'); + }); + + it('does not throw on a malformed URL, falling back to a path split', () => { + // `//` cannot be parsed relative to the placeholder host and throws in + // `new URL`; context creation must still succeed. + const ctx = createRequestContext(req({ url: '//?x=1' })); + expect(ctx.route).toBe('//'); + }); + + it('measures elapsed time as a non-negative integer', () => { + const ctx = createRequestContext(req()); + expect(elapsedMs(ctx)).toBeGreaterThanOrEqual(0); + expect(Number.isInteger(elapsedMs(ctx))).toBe(true); + }); +}); + +describe('request context store', () => { + it('associates and retrieves a context per request', () => { + const request = req(); + const ctx = createRequestContext(request); + setRequestContext(request, ctx); + expect(getRequestContext(request)).toBe(ctx); + }); + + it('returns undefined for a request with no stored context', () => { + expect(getRequestContext(req())).toBeUndefined(); + }); +}); diff --git a/packages/mcp-server/src/__tests__/index.test.ts b/packages/mcp-server/src/__tests__/index.test.ts new file mode 100644 index 000000000..4d395565d --- /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 000000000..7dee3fc63 --- /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__/server.test.ts b/packages/mcp-server/src/__tests__/server.test.ts new file mode 100644 index 000000000..af2233e77 --- /dev/null +++ b/packages/mcp-server/src/__tests__/server.test.ts @@ -0,0 +1,202 @@ +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('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__/token.test.ts b/packages/mcp-server/src/auth/__tests__/token.test.ts new file mode 100644 index 000000000..69cf9b809 --- /dev/null +++ b/packages/mcp-server/src/auth/__tests__/token.test.ts @@ -0,0 +1,135 @@ +import type { IncomingMessage } from 'node:http'; +import { generateKeyPair, type JWTVerifyGetKey, type KeyObject, SignJWT } from 'jose'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { + extractBearerToken, + toPrincipal, + TokenVerificationError, + verifyAccessTokenWithKey, +} from '../token.js'; + +const ISSUER = 'https://tenant.auth0.com/'; +const AUDIENCE = 'https://api.accounter.example'; + +function req(headers: Record = {}): IncomingMessage { + return { headers } as unknown as IncomingMessage; +} + +describe('extractBearerToken', () => { + it('extracts the token from a well-formed header (case-insensitive)', () => { + expect(extractBearerToken(req({ authorization: 'Bearer abc.def.ghi' }))).toBe('abc.def.ghi'); + expect(extractBearerToken(req({ authorization: 'bearer abc' }))).toBe('abc'); + }); + + it('returns null when absent, empty, or non-bearer', () => { + expect(extractBearerToken(req())).toBeNull(); + expect(extractBearerToken(req({ authorization: 'Bearer ' }))).toBeNull(); + expect(extractBearerToken(req({ authorization: 'Basic xyz' }))).toBeNull(); + }); +}); + +describe('toPrincipal', () => { + it('parses subject, email, and scopes from scope + permissions', () => { + const principal = toPrincipal({ + sub: 'user-1', + iss: ISSUER, + aud: AUDIENCE, + email: 'a@b.com', + scope: 'read:charges read:tags', + permissions: ['read:reports', 'read:charges'], + exp: 123, + }); + expect(principal.subject).toBe('user-1'); + expect(principal.email).toBe('a@b.com'); + expect(principal.expiresAt).toBe(123); + expect([...principal.scopes].sort()).toEqual(['read:charges', 'read:reports', 'read:tags']); + }); + + it('throws when the subject claim is missing', () => { + expect(() => toPrincipal({ iss: ISSUER })).toThrow(TokenVerificationError); + }); +}); + +describe('verifyAccessTokenWithKey', () => { + let publicKey: KeyObject; + let privateKey: KeyObject; + + beforeAll(async () => { + ({ publicKey, privateKey } = (await generateKeyPair('RS256')) as { + publicKey: KeyObject; + privateKey: KeyObject; + }); + }); + + async function sign(overrides: { + issuer?: string; + audience?: string; + expiresIn?: string | number; + subject?: string; + }): Promise { + return new SignJWT({ scope: 'read:charges', email: 'a@b.com' }) + .setProtectedHeader({ alg: 'RS256' }) + .setIssuer(overrides.issuer ?? ISSUER) + .setAudience(overrides.audience ?? AUDIENCE) + .setSubject(overrides.subject ?? 'user-1') + .setIssuedAt() + .setExpirationTime(overrides.expiresIn ?? '2h') + .sign(privateKey); + } + + it('accepts a valid token and returns a principal', async () => { + const token = await sign({}); + const principal = await verifyAccessTokenWithKey(token, publicKey, { + issuer: ISSUER, + audience: AUDIENCE, + }); + expect(principal.subject).toBe('user-1'); + expect(principal.scopes).toContain('read:charges'); + }); + + it('rejects a token with the wrong issuer', async () => { + const token = await sign({ issuer: 'https://evil.example/' }); + await expect( + verifyAccessTokenWithKey(token, publicKey, { issuer: ISSUER, audience: AUDIENCE }), + ).rejects.toBeInstanceOf(TokenVerificationError); + }); + + it('rejects a token with the wrong audience', async () => { + const token = await sign({ audience: 'https://other.audience' }); + await expect( + verifyAccessTokenWithKey(token, publicKey, { issuer: ISSUER, audience: AUDIENCE }), + ).rejects.toBeInstanceOf(TokenVerificationError); + }); + + it('rejects an expired token', async () => { + const token = await sign({ expiresIn: Math.floor(Date.now() / 1000) - 60 }); + await expect( + verifyAccessTokenWithKey(token, publicKey, { issuer: ISSUER, audience: AUDIENCE }), + ).rejects.toBeInstanceOf(TokenVerificationError); + }); + + it('rejects a token signed by a different key', async () => { + const other = (await generateKeyPair('RS256')) as { privateKey: KeyObject }; + const token = await new SignJWT({}) + .setProtectedHeader({ alg: 'RS256' }) + .setIssuer(ISSUER) + .setAudience(AUDIENCE) + .setSubject('user-1') + .setExpirationTime('2h') + .sign(other.privateKey); + await expect( + verifyAccessTokenWithKey(token, publicKey, { issuer: ISSUER, audience: AUDIENCE }), + ).rejects.toBeInstanceOf(TokenVerificationError); + }); + + it('propagates infrastructure errors (e.g. JWKS timeout) instead of masking them as invalid', async () => { + const token = await sign({}); + const infra = Object.assign(new Error('jwks unreachable'), { code: 'ERR_JWKS_TIMEOUT' }); + const getKey: JWTVerifyGetKey = () => { + throw infra; + }; + await expect( + verifyAccessTokenWithKey(token, getKey, { issuer: ISSUER, audience: AUDIENCE }), + ).rejects.toBe(infra); + }); +}); diff --git a/packages/mcp-server/src/auth/token.ts b/packages/mcp-server/src/auth/token.ts new file mode 100644 index 000000000..b95e0dc26 --- /dev/null +++ b/packages/mcp-server/src/auth/token.ts @@ -0,0 +1,168 @@ +import type { IncomingMessage } from 'node:http'; +import { jwtVerify, type JWTPayload, type JWTVerifyGetKey, type KeyObject } from 'jose'; + +/** + * Bearer token extraction and Auth0 access-token verification. + * + * Tokens are only ever read from the `Authorization: Bearer ` header — + * never from query params (spec §6.5). Raw token values are never logged. + */ + +const BEARER_RE = /^Bearer[ ]+(.+)$/i; + +/** Extract the bearer token from the Authorization header, or `null`. */ +export function extractBearerToken(req: IncomingMessage): string | null { + const header = req.headers.authorization; + if (typeof header !== 'string') { + return null; + } + const match = BEARER_RE.exec(header.trim()); + if (!match) { + return null; + } + const token = match[1].trim(); + return token.length > 0 ? token : null; +} + +/** Authenticated caller derived from a verified access token. */ +export interface AuthPrincipal { + /** Subject claim (`sub`) — the stable user identifier. */ + subject: string; + /** Token issuer (`iss`). */ + issuer: string; + /** Audience claim (`aud`). */ + audience: string | string[] | undefined; + /** Granted scopes, parsed from the `scope` string and/or `permissions`. */ + scopes: readonly string[]; + /** Email claim, when present. */ + email: string | null; + /** Expiry as a UNIX timestamp (seconds), when present. */ + expiresAt: number | undefined; + /** Full verified claim set for downstream identity mapping. */ + claims: JWTPayload; +} + +/** Raised when an access token fails verification. Carries no token material. */ +export class TokenVerificationError extends Error { + /** RFC 6750 error code surfaced in the WWW-Authenticate challenge. */ + public readonly code = 'invalid_token'; + + constructor(message: string) { + super(message); + this.name = 'TokenVerificationError'; + } +} + +/** Parse space-delimited `scope` and array `permissions` into a scope list. */ +function parseScopes(payload: JWTPayload): string[] { + const scopes = new Set(); + if (typeof payload.scope === 'string') { + for (const scope of payload.scope.split(' ')) { + if (scope.trim()) { + scopes.add(scope.trim()); + } + } + } + const permissions = (payload as { permissions?: unknown }).permissions; + if (Array.isArray(permissions)) { + for (const permission of permissions) { + if (typeof permission === 'string' && permission.trim()) { + scopes.add(permission.trim()); + } + } + } + return [...scopes]; +} + +/** Map a verified JWT payload to an {@link AuthPrincipal}. */ +export function toPrincipal(payload: JWTPayload): AuthPrincipal { + if (!payload.sub) { + throw new TokenVerificationError('token is missing the subject (sub) claim'); + } + const email = payload['email']; + return { + subject: payload.sub, + issuer: payload.iss ?? '', + audience: payload.aud, + scopes: parseScopes(payload), + email: typeof email === 'string' ? email : null, + expiresAt: payload.exp, + claims: payload, + }; +} + +export interface VerifyOptions { + issuer: string; + audience: string; +} + +/** + * jose error codes that mean the *token itself* is invalid (client error → 401) + * as opposed to an infrastructure problem such as a JWKS fetch timeout, which + * should surface as a 5xx and must NOT be masked as an auth failure. + */ +const TOKEN_VALIDATION_CODES = new Set([ + 'ERR_JWT_EXPIRED', + 'ERR_JWT_NOT_ACTIVE', + 'ERR_JWT_CLAIM_VALIDATION_FAILED', + 'ERR_JWT_INVALID', + 'ERR_JWS_INVALID', + 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED', + 'ERR_JWKS_NO_MATCHING_KEY', + 'ERR_JWKS_MULTIPLE_MATCHING_KEYS', + 'ERR_JOSE_ALG_NOT_ALLOWED', + 'ERR_JOSE_NOT_SUPPORTED', +]); + +function isTokenValidationError(error: unknown): boolean { + const code = (error as { code?: unknown })?.code; + return typeof code === 'string' && TOKEN_VALIDATION_CODES.has(code); +} + +/** + * Verify an access token against the given key (a JWKS resolver or a public + * key), enforcing issuer, audience, signature, and expiry. Pure w.r.t. process + * env, so it is directly unit-testable with a local key. + * + * Throws {@link TokenVerificationError} only when the token itself is invalid + * (→ 401). Infrastructure failures (e.g. a JWKS fetch timeout) propagate + * unchanged so the caller can surface a 5xx instead of masking an outage as an + * authentication failure. + */ +export async function verifyAccessTokenWithKey( + token: string, + key: JWTVerifyGetKey | KeyObject | Uint8Array, + options: VerifyOptions, +): Promise { + try { + const verifyOptions = { issuer: options.issuer, audience: options.audience }; + // jose has separate overloads for a static key vs. a key-resolver function; + // branch so the correct one is selected. + const { payload } = + typeof key === 'function' + ? await jwtVerify(token, key, verifyOptions) + : await jwtVerify(token, key, verifyOptions); + return toPrincipal(payload); + } catch (error) { + if (error instanceof TokenVerificationError) { + throw error; + } + if (isTokenValidationError(error)) { + // Normalize to a safe, token-free message. + throw new TokenVerificationError(error instanceof Error ? error.message : 'invalid token'); + } + // Infrastructure/unexpected error — let it propagate (becomes a 5xx). + throw error; + } +} + +// Associate a verified principal with its request for downstream steps. +const principalByRequest = new WeakMap(); + +export function setAuthPrincipal(req: IncomingMessage, principal: AuthPrincipal): void { + principalByRequest.set(req, principal); +} + +export function getAuthPrincipal(req: IncomingMessage): AuthPrincipal | undefined { + return principalByRequest.get(req); +} diff --git a/packages/mcp-server/src/auth/verifier.ts b/packages/mcp-server/src/auth/verifier.ts new file mode 100644 index 000000000..036d4e9ad --- /dev/null +++ b/packages/mcp-server/src/auth/verifier.ts @@ -0,0 +1,38 @@ +import { createRemoteJWKSet, type JWTVerifyGetKey } from 'jose'; +import { env } from '../config/env.js'; +import { verifyAccessTokenWithKey, type AuthPrincipal } from './token.js'; + +/** + * Default access-token verifier wired to the configured Auth0 tenant. + * + * The JWKS is fetched lazily and cached per JWKS URL (jose's + * `createRemoteJWKSet` also caches keys and handles rotation), mirroring the + * server package's auth setup. + */ + +const jwksCache = new Map(); + +function getRemoteJwks(jwksUrl: string): JWTVerifyGetKey { + let jwks = jwksCache.get(jwksUrl); + if (!jwks) { + jwks = createRemoteJWKSet(new URL(jwksUrl)); + jwksCache.set(jwksUrl, jwks); + } + return jwks; +} + +/** + * Verify an Auth0 access token using the configured issuer, audience, and + * JWKS. Throws `TokenVerificationError` on any failure. + */ +export function verifyAccessToken(token: string): Promise { + return verifyAccessTokenWithKey(token, getRemoteJwks(env.auth0.jwksUrl), { + issuer: env.auth0.issuerUrl, + audience: env.auth0.audience, + }); +} + +/** Test-only: clear the memoized JWKS resolvers. */ +export function resetJwksCache(): void { + jwksCache.clear(); +} diff --git a/packages/mcp-server/src/config/__tests__/env.test.ts b/packages/mcp-server/src/config/__tests__/env.test.ts new file mode 100644 index 000000000..79f4bb24f --- /dev/null +++ b/packages/mcp-server/src/config/__tests__/env.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest'; +import { EnvValidationError, parseEnv } from '../env.js'; + +const validEnv: NodeJS.ProcessEnv = { + MCP_PUBLIC_BASE_URL: 'https://mcp.example.com', + AUTH0_ISSUER_URL: 'https://tenant.auth0.com/', + AUTH0_AUDIENCE: 'https://api.accounter.example', + GRAPHQL_UPSTREAM_URL: 'http://localhost:4000/graphql', +}; + +describe('parseEnv — valid configuration', () => { + it('parses required vars and applies secure defaults', () => { + const config = parseEnv(validEnv); + + expect(config.server.port).toBe(3100); + expect(config.server.enabled).toBe(true); + expect(config.server.toolAllowlist).toEqual([]); + expect(config.auth0.issuerUrl).toBe('https://tenant.auth0.com/'); + expect(config.auth0.audience).toBe('https://api.accounter.example'); + expect(config.upstream.graphqlUrl).toBe('http://localhost:4000/graphql'); + expect(config.upstream.timeoutMs).toBe(10_000); + expect(config.rateLimit.raw).toBe(''); + }); + + it('strips the trailing slash from the public base url', () => { + const config = parseEnv({ ...validEnv, MCP_PUBLIC_BASE_URL: 'https://mcp.example.com/' }); + expect(config.server.publicBaseUrl).toBe('https://mcp.example.com'); + }); + + it('derives the JWKS url from the issuer when not provided', () => { + const config = parseEnv(validEnv); + expect(config.auth0.jwksUrl).toBe('https://tenant.auth0.com/.well-known/jwks.json'); + }); + + it('honors an explicit JWKS url override', () => { + const config = parseEnv({ + ...validEnv, + AUTH0_JWKS_URL: 'https://tenant.auth0.com/custom/jwks.json', + }); + expect(config.auth0.jwksUrl).toBe('https://tenant.auth0.com/custom/jwks.json'); + }); + + it('coerces numeric vars and parses the tool allowlist', () => { + const config = parseEnv({ + ...validEnv, + MCP_SERVER_PORT: '8080', + GRAPHQL_UPSTREAM_TIMEOUT_MS: '2500', + MCP_TOOL_ALLOWLIST: 'charges_search, tags_lookup ,', + }); + expect(config.server.port).toBe(8080); + expect(config.upstream.timeoutMs).toBe(2500); + expect(config.server.toolAllowlist).toEqual(['charges_search', 'tags_lookup']); + }); + + it('treats empty strings as unset and falls back to defaults', () => { + const config = parseEnv({ + ...validEnv, + MCP_SERVER_PORT: '', + MCP_ENABLED: '', + MCP_TOOL_ALLOWLIST: '', + }); + expect(config.server.port).toBe(3100); + expect(config.server.enabled).toBe(true); + expect(config.server.toolAllowlist).toEqual([]); + }); + + it('supports disabling the server via MCP_ENABLED=0', () => { + const config = parseEnv({ ...validEnv, MCP_ENABLED: '0' }); + expect(config.server.enabled).toBe(false); + }); +}); + +describe('parseEnv — invalid configuration', () => { + it('throws when a required var is missing', () => { + const { MCP_PUBLIC_BASE_URL: _omitted, ...rest } = validEnv; + expect(() => parseEnv(rest)).toThrow(EnvValidationError); + }); + + it('reports every missing required var in the error', () => { + try { + parseEnv({}); + expect.unreachable('expected parseEnv to throw'); + } catch (error) { + expect(error).toBeInstanceOf(EnvValidationError); + const report = (error as EnvValidationError).report; + expect(report).toContain('MCP_PUBLIC_BASE_URL'); + expect(report).toContain('AUTH0_ISSUER_URL'); + expect(report).toContain('AUTH0_AUDIENCE'); + expect(report).toContain('GRAPHQL_UPSTREAM_URL'); + } + }); + + it('rejects a malformed URL', () => { + expect(() => parseEnv({ ...validEnv, GRAPHQL_UPSTREAM_URL: 'not-a-url' })).toThrow( + EnvValidationError, + ); + }); + + it('rejects a non-numeric port', () => { + expect(() => parseEnv({ ...validEnv, MCP_SERVER_PORT: 'abc' })).toThrow(EnvValidationError); + }); + + it('rejects an out-of-range port', () => { + expect(() => parseEnv({ ...validEnv, MCP_SERVER_PORT: '70000' })).toThrow(EnvValidationError); + }); + + it('rejects an empty audience', () => { + expect(() => parseEnv({ ...validEnv, AUTH0_AUDIENCE: ' ' })).not.toThrow(); + // whitespace is a non-empty string at the schema level; emptiness is only + // enforced against the literal empty string. + expect(() => parseEnv({ ...validEnv, AUTH0_AUDIENCE: '' })).toThrow(EnvValidationError); + }); +}); diff --git a/packages/mcp-server/src/config/env.ts b/packages/mcp-server/src/config/env.ts new file mode 100644 index 000000000..2b7261e1c --- /dev/null +++ b/packages/mcp-server/src/config/env.ts @@ -0,0 +1,201 @@ +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { config as dotenv } from 'dotenv'; +import zod from 'zod'; + +/** + * Environment configuration for the Accounter MCP server. + * + * All configuration is validated at startup with a strict schema. Missing + * required variables or malformed values cause the process to exit immediately + * with a clear, actionable error (fail-fast) rather than failing later at + * request time. + * + * | Variable | Required | Default | Description | + * | --------------------------- | -------- | ------------------------ | ------------------------------------------------------------------ | + * | MCP_PUBLIC_BASE_URL | yes | — | Public HTTPS origin of this MCP server (used in OAuth metadata). | + * | AUTH0_ISSUER_URL | yes | — | Auth0 issuer/tenant URL used to validate access tokens. | + * | AUTH0_AUDIENCE | yes | — | Expected `aud` claim for incoming access tokens. | + * | GRAPHQL_UPSTREAM_URL | yes | — | Base URL of the Accounter GraphQL server the tools call. | + * | MCP_SERVER_PORT | no | 3100 | TCP port the HTTP transport listens on. | + * | MCP_ENABLED | no | 1 | Master kill-switch (`1` on / `0` off). | + * | MCP_TOOL_ALLOWLIST | no | '' (none) | Comma-separated tool names allowed in production (least priv). | + * | AUTH0_JWKS_URL | no | derived from issuer | JWKS endpoint; defaults to `/.well-known/jwks.json`. | + * | GRAPHQL_UPSTREAM_TIMEOUT_MS | no | 10000 | Upstream GraphQL request timeout budget in milliseconds. | + * | MCP_RATE_LIMIT_CONFIG | no | '' (defaults applied) | Optional rate-limit override spec (parsed by the limiter later). | + * + * Secrets are never embedded here; they are supplied via the environment only. + */ + +/** Treat an empty string (`''`) as `undefined` so defaults apply cleanly. */ +const emptyStringAsUndefined = (input: T) => + zod.preprocess((value: unknown) => (value === '' ? undefined : value), input); + +const booleanFlag = (defaultValue: '0' | '1') => + emptyStringAsUndefined( + zod + .union([zod.literal('1'), zod.literal('0')]) + .optional() + .default(defaultValue), + ); + +/** + * Strict schema for the raw process environment. `.strict()` is intentionally + * NOT used here because the ambient environment contains many unrelated + * variables; instead we only read the keys we know about. + */ +export const envSchema = zod.object({ + // --- Required (no safe default) --- + MCP_PUBLIC_BASE_URL: zod.url({ error: 'MCP_PUBLIC_BASE_URL must be a valid URL' }), + AUTH0_ISSUER_URL: zod.url({ error: 'AUTH0_ISSUER_URL must be a valid URL' }), + AUTH0_AUDIENCE: zod.string().min(1, { error: 'AUTH0_AUDIENCE must be a non-empty string' }), + GRAPHQL_UPSTREAM_URL: zod.url({ error: 'GRAPHQL_UPSTREAM_URL must be a valid URL' }), + + // --- Optional with secure defaults --- + MCP_SERVER_PORT: emptyStringAsUndefined( + zod.coerce.number().int().positive().max(65_535).optional().default(3100), + ), + MCP_ENABLED: booleanFlag('1'), + // Least-privilege default: empty allowlist means no tools are exposed unless + // explicitly enabled. The registry enforces this in later prompts. + MCP_TOOL_ALLOWLIST: emptyStringAsUndefined(zod.string().optional().default('')), + AUTH0_JWKS_URL: emptyStringAsUndefined( + zod.url({ error: 'AUTH0_JWKS_URL must be a valid URL' }).optional(), + ), + GRAPHQL_UPSTREAM_TIMEOUT_MS: emptyStringAsUndefined( + zod.coerce.number().int().positive().max(120_000).optional().default(10_000), + ), + MCP_RATE_LIMIT_CONFIG: emptyStringAsUndefined(zod.string().optional().default('')), +}); + +export type RawEnv = zod.infer; + +/** Typed, normalized configuration object consumed by the rest of the server. */ +export interface AppConfig { + server: { + port: number; + /** Public origin without a trailing slash. */ + publicBaseUrl: string; + enabled: boolean; + /** Parsed tool allowlist; empty array means "no tools exposed". */ + toolAllowlist: readonly string[]; + }; + auth0: { + issuerUrl: string; + audience: string; + jwksUrl: string; + }; + upstream: { + graphqlUrl: string; + timeoutMs: number; + }; + rateLimit: { + /** Raw config spec; parsed by the rate limiter in a later prompt. */ + raw: string; + }; +} + +/** Thrown when environment validation fails. Carries a human-readable report. */ +export class EnvValidationError extends Error { + constructor(public readonly report: string) { + super(`Invalid environment configuration:\n${report}`); + this.name = 'EnvValidationError'; + } +} + +const stripTrailingSlash = (url: string): string => url.replace(/\/+$/, ''); + +const deriveJwksUrl = (issuerUrl: string): string => + new URL('.well-known/jwks.json', `${stripTrailingSlash(issuerUrl)}/`).toString(); + +/** + * Validate a raw environment source and build the typed config. Throws + * {@link EnvValidationError} on any validation failure. Pure and side-effect + * free, so it is directly unit-testable. + */ +export function parseEnv(source: NodeJS.ProcessEnv): AppConfig { + const result = envSchema.safeParse(source); + if (!result.success) { + throw new EnvValidationError(JSON.stringify(zod.treeifyError(result.error), null, 2)); + } + + const raw = result.data; + return { + server: { + port: raw.MCP_SERVER_PORT, + publicBaseUrl: stripTrailingSlash(raw.MCP_PUBLIC_BASE_URL), + enabled: raw.MCP_ENABLED === '1', + toolAllowlist: raw.MCP_TOOL_ALLOWLIST.split(',') + .map(name => name.trim()) + .filter(Boolean), + }, + auth0: { + issuerUrl: raw.AUTH0_ISSUER_URL, + audience: raw.AUTH0_AUDIENCE, + jwksUrl: raw.AUTH0_JWKS_URL ?? deriveJwksUrl(raw.AUTH0_ISSUER_URL), + }, + upstream: { + graphqlUrl: raw.GRAPHQL_UPSTREAM_URL, + timeoutMs: raw.GRAPHQL_UPSTREAM_TIMEOUT_MS, + }, + rateLimit: { + raw: raw.MCP_RATE_LIMIT_CONFIG, + }, + }; +} + +/** + * Load `.env` (package-root relative, or `TEST_ENV_FILE` when set) and validate. + * On failure, prints a clear report and exits the process (fail-fast startup). + */ +export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppConfig { + const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); + dotenv({ + path: + process.env.TEST_ENV_FILE && process.env.TEST_ENV_FILE.trim() !== '' + ? process.env.TEST_ENV_FILE + : [resolve(packageRoot, '.env')], + // Only surface dotenv's own debug noise outside of release builds. + debug: process.env.RELEASE ? false : undefined, + // Populate the caller-provided source (defaults to process.env) rather than + // always mutating process.env, so a custom source is honored end to end. + processEnv: source, + }); + + try { + return parseEnv(source); + } catch (error) { + if (error instanceof EnvValidationError) { + // eslint-disable-next-line no-console + console.error('[env] Invalid environment variables:\n' + error.report); + process.exit(1); + } + throw error; + } +} + +let cachedConfig: AppConfig | undefined; + +/** + * Lazily load and memoize the validated configuration. Deferring the load keeps + * merely importing this module side-effect free (so unit tests can import the + * pure helpers without triggering fail-fast `process.exit`), while the first + * real access still fails fast on a bad environment. + */ +export function getEnv(): AppConfig { + cachedConfig ??= loadEnv(); + return cachedConfig; +} + +/** Test-only hook to reset the memoized config. */ +export function resetEnvCache(): void { + cachedConfig = undefined; +} + +/** + * Validated, typed configuration for this process. Access is lazy: the + * environment is loaded and validated on first property read. + */ +export const env: AppConfig = new Proxy({} as AppConfig, { + get: (_target, property: keyof AppConfig) => getEnv()[property], +}) as AppConfig; diff --git a/packages/mcp-server/src/context.ts b/packages/mcp-server/src/context.ts new file mode 100644 index 000000000..77898be92 --- /dev/null +++ b/packages/mcp-server/src/context.ts @@ -0,0 +1,80 @@ +import { randomUUID } from 'node:crypto'; +import type { IncomingMessage } from 'node:http'; + +/** + * Per-request context for observability. + * + * A `requestId` is minted fresh for every request. A `correlationId` is taken + * from the inbound `x-correlation-id` header when present (so a trace can span + * multiple hops) and otherwise generated. The correlation id is echoed back on + * the response and propagated to upstream calls in later steps. + */ +export interface RequestContext { + /** Unique id for this single request. */ + requestId: string; + /** Trace id spanning hops; inherited from the inbound header when present. */ + correlationId: string; + /** HTTP method (e.g. GET, POST). */ + method: string; + /** Request path (pathname only — never the query string). */ + route: string; + /** High-resolution start timestamp (ms) for latency measurement. */ + startTimeMs: number; +} + +/** Header used to inherit/propagate the correlation id. */ +export const CORRELATION_ID_HEADER = 'x-correlation-id'; + +export function generateId(): string { + return randomUUID(); +} + +function headerValue(req: IncomingMessage, name: string): string | undefined { + const value = req.headers[name]; + return Array.isArray(value) ? value[0] : value; +} + +/** + * Extract the pathname from a request URL, dropping the query string. Falls back + * to a manual split if the URL is malformed (so a bad `req.url` can never throw + * out of context creation and hang the request). + */ +function extractRoute(rawUrl: string | undefined): string { + const url = rawUrl ?? '/'; + try { + // Only the pathname is retained; the host is a placeholder and the query + // string is dropped so we never log sensitive query params. + return new URL(url, 'http://localhost').pathname; + } catch { + return url.split('?')[0]; + } +} + +/** Build a fresh {@link RequestContext} from an incoming request. */ +export function createRequestContext(req: IncomingMessage): RequestContext { + const inboundCorrelationId = headerValue(req, CORRELATION_ID_HEADER)?.trim(); + const route = extractRoute(req.url); + return { + requestId: generateId(), + correlationId: inboundCorrelationId || generateId(), + method: req.method ?? 'UNKNOWN', + route, + startTimeMs: performance.now(), + }; +} + +/** Elapsed time since the context was created, in whole milliseconds. */ +export function elapsedMs(context: RequestContext): number { + return Math.round(performance.now() - context.startTimeMs); +} + +// Associate a context with its request without mutating the request's type. +const contextByRequest = new WeakMap(); + +export function setRequestContext(req: IncomingMessage, context: RequestContext): void { + contextByRequest.set(req, context); +} + +export function getRequestContext(req: IncomingMessage): RequestContext | undefined { + return contextByRequest.get(req); +} diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts new file mode 100644 index 000000000..73129271e --- /dev/null +++ b/packages/mcp-server/src/index.ts @@ -0,0 +1,52 @@ +/** + * 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 { fileURLToPath } from 'node:url'; +import { log } from './logger.js'; +import { start } from './server.js'; + +export const PACKAGE_NAME = '@accounter/mcp-server'; + +export { start } from './server.js'; + +/** + * 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 via fileURLToPath so paths with +// spaces or other special characters (URL-encoded in import.meta.url) match. +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + main(); +} diff --git a/packages/mcp-server/src/logger.ts b/packages/mcp-server/src/logger.ts new file mode 100644 index 000000000..2bd21f85d --- /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 000000000..aea4b29e6 --- /dev/null +++ b/packages/mcp-server/src/mcp/__tests__/handler.test.ts @@ -0,0 +1,220 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { Readable } from 'node:stream'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { TokenVerificationError } from '../../auth/token.js'; +import { verifyAccessToken } from '../../auth/verifier.js'; +import { handleMcpBody, MCP_PROTOCOL_VERSION, mcpHttpHandler } from '../handler.js'; +import type { JsonRpcErrorResponse, JsonRpcSuccess } from '../jsonrpc.js'; +import { JsonRpcErrorCode } from '../jsonrpc.js'; +import { SMOKE_TOOL_NAME } from '../tools.js'; + +// The MCP handler verifies bearer tokens via the env-backed verifier, which +// would otherwise fetch a remote JWKS. Mock it so tests stay hermetic. +vi.mock('../../auth/verifier.js', () => ({ verifyAccessToken: vi.fn() })); +const mockVerify = vi.mocked(verifyAccessToken); + +const PRINCIPAL = { + subject: 'user-1', + issuer: 'https://tenant.auth0.com/', + audience: 'aud', + scopes: [], + email: null, + expiresAt: undefined, + claims: { sub: 'user-1' }, +}; + +function rpc(method: string, params?: unknown, id: string | number | null = 1) { + return JSON.stringify({ jsonrpc: '2.0', id, method, ...(params !== undefined && { params }) }); +} + +describe('handleMcpBody — method dispatch', () => { + it('handles initialize', () => { + const res = handleMcpBody(rpc('initialize')) as JsonRpcSuccess; + const result = res.result as Record; + expect(result.protocolVersion).toBe(MCP_PROTOCOL_VERSION); + expect(result.capabilities).toEqual({ tools: { listChanged: false } }); + expect(result.serverInfo).toMatchObject({ name: '@accounter/mcp-server' }); + }); + + it('handles ping', () => { + const res = handleMcpBody(rpc('ping')) as JsonRpcSuccess; + expect(res.result).toEqual({}); + }); + + it('lists the smoke tool', () => { + const res = handleMcpBody(rpc('tools/list')) as JsonRpcSuccess; + const result = res.result as { tools: Array<{ name: string }> }; + expect(result.tools).toHaveLength(1); + expect(result.tools[0].name).toBe(SMOKE_TOOL_NAME); + }); + + it('calls the smoke tool and echoes the message', () => { + const res = handleMcpBody( + rpc('tools/call', { name: SMOKE_TOOL_NAME, arguments: { message: 'hi' } }), + ) as JsonRpcSuccess; + expect(res.result).toEqual({ content: [{ type: 'text', text: 'pong: hi' }], isError: false }); + }); + + it('returns InvalidParams for an unknown tool', () => { + const res = handleMcpBody(rpc('tools/call', { name: 'nope' })) as JsonRpcErrorResponse; + expect(res.error.code).toBe(JsonRpcErrorCode.InvalidParams); + }); + + it('returns MethodNotFound for an unsupported method', () => { + const res = handleMcpBody(rpc('resources/list')) as JsonRpcErrorResponse; + expect(res.error.code).toBe(JsonRpcErrorCode.MethodNotFound); + expect(res.error.message).toContain('resources/list'); + }); + + it('returns null for a notification (no id)', () => { + expect(handleMcpBody(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }))).toBeNull(); + }); +}); + +describe('handleMcpBody — malformed input', () => { + it('maps invalid JSON to ParseError with null id', () => { + const res = handleMcpBody('{ not json') as JsonRpcErrorResponse; + expect(res.id).toBeNull(); + expect(res.error.code).toBe(JsonRpcErrorCode.ParseError); + }); + + it('rejects JSON-RPC batches', () => { + const res = handleMcpBody('[{"jsonrpc":"2.0","id":1,"method":"ping"}]') as JsonRpcErrorResponse; + expect(res.error.code).toBe(JsonRpcErrorCode.InvalidRequest); + }); + + it('rejects a malformed request shape', () => { + const res = handleMcpBody('{"jsonrpc":"1.0","method":"ping"}') as JsonRpcErrorResponse; + expect(res.error.code).toBe(JsonRpcErrorCode.InvalidRequest); + }); +}); + +// --------------------------------------------------------------------------- +// HTTP adapter +// --------------------------------------------------------------------------- + +function mockReq( + body: string, + 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('propagates infrastructure errors instead of returning a misleading 401', async () => { + // An error without a token-validation code (e.g. a JWKS outage) must bubble + // up so the request becomes a 5xx, not a 401. + mockVerify.mockRejectedValue(new Error('jwks endpoint unreachable')); + + const res = mockRes(); + await expect(mcpHttpHandler(mockReq(rpc('tools/list')), res)).rejects.toThrow( + 'jwks endpoint unreachable', + ); + expect(res.writeHead).not.toHaveBeenCalledWith(401, expect.anything()); + }); +}); + +describe('hasBearerToken', () => { + 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 000000000..f2bf9feec --- /dev/null +++ b/packages/mcp-server/src/mcp/__tests__/jsonrpc.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { + asJsonRpcRequest, + failure, + isNotification, + JSON_RPC_VERSION, + JsonRpcErrorCode, + success, +} from '../jsonrpc.js'; + +describe('success / failure builders', () => { + it('builds a success envelope', () => { + expect(success(1, { ok: true })).toEqual({ + jsonrpc: JSON_RPC_VERSION, + id: 1, + result: { ok: true }, + }); + }); + + it('builds an error envelope and omits absent data', () => { + const err = failure('x', JsonRpcErrorCode.MethodNotFound, 'nope'); + expect(err).toEqual({ + jsonrpc: JSON_RPC_VERSION, + id: 'x', + error: { code: -32_601, message: 'nope' }, + }); + expect('data' in err.error).toBe(false); + }); + + it('includes data when provided', () => { + const err = failure(null, JsonRpcErrorCode.InvalidParams, 'bad', { field: 'name' }); + expect(err.error.data).toEqual({ field: 'name' }); + }); +}); + +describe('asJsonRpcRequest', () => { + it('accepts a valid request', () => { + expect(asJsonRpcRequest({ jsonrpc: '2.0', id: 1, method: 'ping' })).not.toBeNull(); + }); + + it('accepts a notification (no id)', () => { + const req = asJsonRpcRequest({ jsonrpc: '2.0', method: 'notifications/initialized' }); + expect(req).not.toBeNull(); + expect(isNotification(req!)).toBe(true); + }); + + it.each([ + ['null', null], + ['a string', 'hello'], + ['wrong version', { jsonrpc: '1.0', method: 'ping' }], + ['missing method', { jsonrpc: '2.0', id: 1 }], + ['non-string method', { jsonrpc: '2.0', method: 42 }], + ['object id', { jsonrpc: '2.0', id: {}, method: 'ping' }], + ['primitive params', { jsonrpc: '2.0', id: 1, method: 'ping', params: 'oops' }], + ])('rejects %s', (_label, value) => { + expect(asJsonRpcRequest(value)).toBeNull(); + }); + + it('accepts object and array params', () => { + expect(asJsonRpcRequest({ jsonrpc: '2.0', id: 1, method: 'x', params: { a: 1 } })).not.toBeNull(); + expect(asJsonRpcRequest({ jsonrpc: '2.0', id: 1, method: 'x', params: [1, 2] })).not.toBeNull(); + }); +}); + +describe('isNotification', () => { + it('is true only when id is absent', () => { + expect(isNotification({ jsonrpc: '2.0', method: 'x' })).toBe(true); + expect(isNotification({ jsonrpc: '2.0', id: null, method: 'x' })).toBe(false); + expect(isNotification({ jsonrpc: '2.0', id: 0, method: 'x' })).toBe(false); + }); +}); diff --git a/packages/mcp-server/src/mcp/handler.ts b/packages/mcp-server/src/mcp/handler.ts new file mode 100644 index 000000000..7d900fc45 --- /dev/null +++ b/packages/mcp-server/src/mcp/handler.ts @@ -0,0 +1,235 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { + extractBearerToken, + setAuthPrincipal, + TokenVerificationError, + type AuthPrincipal, +} from '../auth/token.js'; +import { verifyAccessToken } from '../auth/verifier.js'; +import { env } from '../config/env.js'; +import { getRequestContext } from '../context.js'; +import { createRequestLogger, log } from '../logger.js'; +import { sendUnauthorized } from '../oauth/challenge.js'; +import { protectedResourceMetadataUrl } from '../oauth/metadata.js'; +import { getServiceVersion, SERVICE_NAME } from '../version.js'; +import { + asJsonRpcRequest, + failure, + isNotification, + JsonRpcErrorCode, + success, + type JsonRpcRequest, + type JsonRpcResponse, +} from './jsonrpc.js'; +import { listedTools, runSmokeTool, SMOKE_TOOL_NAME } from './tools.js'; + +/** + * MCP transport route (Streamable HTTP) — request dispatch skeleton. + * + * Handles the JSON-RPC methods needed to establish a session and list tools. + * It is intentionally auth-agnostic for now (authentication and per-tool + * authorization are layered on in later steps) and performs NO upstream + * GraphQL calls. Unknown methods get a deterministic JSON-RPC "method not + * found" error. + */ + +/** MCP protocol revision this server implements. */ +export const MCP_PROTOCOL_VERSION = '2025-06-18'; + +export const MCP_SERVER_INFO = { + name: SERVICE_NAME, + version: getServiceVersion(), +}; + +/** Max accepted request body size (bounded input, per spec §9.1). */ +export const MAX_MCP_BODY_BYTES = 1_000_000; + +/** + * Dispatch a single JSON-RPC request to its MCP method handler. Returns the + * response, or `null` for notifications (which must not be answered). + */ +export function handleRpcRequest(request: JsonRpcRequest): JsonRpcResponse | null { + const { method } = request; + + // Notifications carry no id and expect no reply (e.g. notifications/initialized). + if (isNotification(request)) { + return null; + } + + const id = request.id ?? null; + + switch (method) { + case 'initialize': + return success(id, { + protocolVersion: MCP_PROTOCOL_VERSION, + capabilities: { tools: { listChanged: false } }, + serverInfo: MCP_SERVER_INFO, + }); + + case 'ping': + return success(id, {}); + + case 'tools/list': + return success(id, { tools: listedTools }); + + case 'tools/call': { + const params = (request.params ?? {}) as { name?: unknown; arguments?: unknown }; + if (params.name === SMOKE_TOOL_NAME) { + return success(id, runSmokeTool(params.arguments)); + } + // The `tools/call` method itself is supported; an unrecognized tool name + // is an invalid parameter, not an unsupported method. + return failure(id, JsonRpcErrorCode.InvalidParams, `Unknown tool: ${String(params.name)}`); + } + + default: + return failure(id, JsonRpcErrorCode.MethodNotFound, `Unsupported method: ${method}`); + } +} + +/** + * Process a raw (already string-decoded) request body into a JSON-RPC response. + * Returns `null` for notifications. Never throws for malformed input — it maps + * to the appropriate JSON-RPC error instead. + */ +export function handleMcpBody(raw: string): JsonRpcResponse | null { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return failure(null, JsonRpcErrorCode.ParseError, 'Parse error: body is not valid JSON'); + } + + // JSON-RPC batching is not supported by MCP 2025-06-18. + if (Array.isArray(parsed)) { + return failure(null, JsonRpcErrorCode.InvalidRequest, 'Batch requests are not supported'); + } + + const request = asJsonRpcRequest(parsed); + if (!request) { + return failure(null, JsonRpcErrorCode.InvalidRequest, 'Invalid JSON-RPC 2.0 request'); + } + + return handleRpcRequest(request); +} + +function readBody(req: IncomingMessage, maxBytes: number): Promise { + return new Promise((resolve, reject) => { + let size = 0; + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => { + size += chunk.length; + if (size > maxBytes) { + // Pause (don't destroy) the stream so the caller can still write the + // 413 response before the socket is closed. + req.pause(); + reject(new Error('PAYLOAD_TOO_LARGE')); + return; + } + chunks.push(chunk); + }); + req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + req.on('error', reject); + }); +} + +function sendJson(res: ServerResponse, statusCode: number, body: unknown): void { + res.writeHead(statusCode, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); +} + +/** + * 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 token = extractBearerToken(req); + if (!token) { + sendUnauthorized(res, { + resourceMetadataUrl: protectedResourceMetadataUrl(env.server.publicBaseUrl), + }); + return null; + } + + try { + const principal = await verifyAccessToken(token); + setAuthPrincipal(req, principal); + return principal; + } catch (error) { + // Only an invalid token is a 401; infrastructure failures (e.g. a JWKS + // outage) propagate so the request surfaces as a 5xx rather than a + // misleading auth error. + if (!(error instanceof TokenVerificationError)) { + throw error; + } + // Log the reason only — never the token. + const context = getRequestContext(req); + if (context) { + createRequestLogger(context).warn('access token verification failed', { + reason: error.message, + }); + } else { + log('warn', 'access token verification failed', { reason: error.message }); + } + sendUnauthorized(res, { + resourceMetadataUrl: protectedResourceMetadataUrl(env.server.publicBaseUrl), + error: 'invalid_token', + errorDescription: 'The access token is invalid or expired', + }); + return null; + } +} + +/** + * HTTP handler for `POST /mcp`. Authenticates the caller (extract + verify the + * bearer token), reads the JSON-RPC body, dispatches it, and writes the + * response as `application/json`. Notifications get `202 Accepted` with no body. + */ +export async function mcpHttpHandler(req: IncomingMessage, res: ServerResponse): Promise { + const principal = await authenticate(req, res); + if (!principal) { + return; + } + + let raw: string; + try { + raw = await readBody(req, MAX_MCP_BODY_BYTES); + } catch (error) { + if (error instanceof Error && error.message === 'PAYLOAD_TOO_LARGE') { + // Close the connection cleanly after the 413 is flushed — the request + // body was only partially consumed, so the socket cannot be reused. + res.setHeader('Connection', 'close'); + sendJson(res, 413, failure(null, JsonRpcErrorCode.InvalidRequest, 'Request body too large')); + res.on('finish', () => req.destroy()); + return; + } + log('error', 'failed to read MCP request body', { error: String(error) }); + sendJson(res, 400, failure(null, JsonRpcErrorCode.ParseError, 'Failed to read request body')); + return; + } + + const response = handleMcpBody(raw); + + if (response === null) { + // Notification: acknowledge without a JSON-RPC response body. + res.writeHead(202); + res.end(); + return; + } + + sendJson(res, 200, response); +} diff --git a/packages/mcp-server/src/mcp/jsonrpc.ts b/packages/mcp-server/src/mcp/jsonrpc.ts new file mode 100644 index 000000000..bf724aa66 --- /dev/null +++ b/packages/mcp-server/src/mcp/jsonrpc.ts @@ -0,0 +1,96 @@ +/** + * Minimal JSON-RPC 2.0 primitives used by the MCP transport. + * + * MCP speaks JSON-RPC 2.0. This module intentionally implements only the small + * subset the connector needs, avoiding a heavyweight framework dependency while + * the protocol surface is still small. It can be swapped for the official MCP + * SDK later without changing tool handlers. + */ + +export const JSON_RPC_VERSION = '2.0'; + +/** Standard JSON-RPC 2.0 error codes plus the range reserved for the server. */ +export const JsonRpcErrorCode = { + ParseError: -32_700, + InvalidRequest: -32_600, + MethodNotFound: -32_601, + InvalidParams: -32_602, + InternalError: -32_603, +} as const; + +export type JsonRpcId = string | number | null; + +export interface JsonRpcRequest { + jsonrpc: typeof JSON_RPC_VERSION; + id?: JsonRpcId; + method: string; + params?: unknown; +} + +export interface JsonRpcSuccess { + jsonrpc: typeof JSON_RPC_VERSION; + id: JsonRpcId; + result: unknown; +} + +export interface JsonRpcErrorResponse { + jsonrpc: typeof JSON_RPC_VERSION; + id: JsonRpcId; + error: { + code: number; + message: string; + data?: unknown; + }; +} + +export type JsonRpcResponse = JsonRpcSuccess | JsonRpcErrorResponse; + +export function success(id: JsonRpcId, result: unknown): JsonRpcSuccess { + return { jsonrpc: JSON_RPC_VERSION, id, result }; +} + +export function failure( + id: JsonRpcId, + code: number, + message: string, + data?: unknown, +): JsonRpcErrorResponse { + return { + jsonrpc: JSON_RPC_VERSION, + id, + error: { code, message, ...(data !== undefined && { data }) }, + }; +} + +/** + * Validate that an arbitrary parsed value is a well-formed JSON-RPC request. + * Returns the narrowed request or `null` when the shape is invalid. + */ +export function asJsonRpcRequest(value: unknown): JsonRpcRequest | null { + if (typeof value !== 'object' || value === null) { + return null; + } + const candidate = value as Record; + if (candidate.jsonrpc !== JSON_RPC_VERSION) { + return null; + } + if (typeof candidate.method !== 'string') { + return null; + } + const id = candidate.id; + if (id !== undefined && id !== null && typeof id !== 'string' && typeof id !== 'number') { + return null; + } + // Per JSON-RPC 2.0, `params` (when present) must be a structured value — + // an object or an array — never a primitive. + const params = candidate.params; + if (params !== undefined && (typeof params !== 'object' || params === null)) { + return null; + } + return candidate as unknown as JsonRpcRequest; +} + +/** A request with no `id` is a notification: the server must not reply. */ +export function isNotification(request: JsonRpcRequest): boolean { + return request.id === undefined; +} diff --git a/packages/mcp-server/src/mcp/tools.ts b/packages/mcp-server/src/mcp/tools.ts new file mode 100644 index 000000000..6861e28bb --- /dev/null +++ b/packages/mcp-server/src/mcp/tools.ts @@ -0,0 +1,70 @@ +/** + * Placeholder tool surface for the MCP transport skeleton. + * + * This file exists only to prove the list-tools / dispatch path end-to-end. It + * exposes a single, clearly-marked internal smoke tool and NO production + * capabilities. The curated, authorization-gated tool registry (with strict + * input/output schemas) replaces this in later steps; the smoke tool is removed + * or folded into that registry at wiring time. + */ + +/** JSON Schema (draft-07 subset) describing a tool's input. */ +export interface ToolInputSchema { + type: 'object'; + properties?: Record; + required?: string[]; + additionalProperties?: boolean; +} + +/** MCP tool descriptor as returned by `tools/list`. */ +export interface ToolDescriptor { + name: string; + description: string; + inputSchema: ToolInputSchema; +} + +/** MCP `tools/call` result content block. */ +export interface ToolTextContent { + type: 'text'; + text: string; +} + +export interface ToolCallResult { + content: ToolTextContent[]; + isError?: boolean; +} + +export const SMOKE_TOOL_NAME = 'accounter_smoke_ping'; + +export const smokeTool: ToolDescriptor = { + name: SMOKE_TOOL_NAME, + description: + 'Internal connectivity smoke test. Echoes the provided message back. Not a production capability — used only to verify the MCP transport is reachable.', + inputSchema: { + type: 'object', + properties: { + message: { + type: 'string', + description: 'Text to echo back.', + }, + }, + additionalProperties: false, + }, +}; + +/** Tools advertised by `tools/list` in the current (skeleton) phase. */ +export const listedTools: readonly ToolDescriptor[] = [smokeTool]; + +/** Execute the smoke tool. Pure and side-effect free — no upstream calls. */ +export function runSmokeTool(args: unknown): ToolCallResult { + const message = + typeof args === 'object' && + args !== null && + typeof (args as { message?: unknown }).message === 'string' + ? (args as { message: string }).message + : ''; + return { + content: [{ type: 'text', text: `pong: ${message}` }], + isError: false, + }; +} diff --git a/packages/mcp-server/src/oauth/__tests__/challenge.test.ts b/packages/mcp-server/src/oauth/__tests__/challenge.test.ts new file mode 100644 index 000000000..223679d35 --- /dev/null +++ b/packages/mcp-server/src/oauth/__tests__/challenge.test.ts @@ -0,0 +1,85 @@ +import type { ServerResponse } from 'node:http'; +import { describe, expect, it, vi } from 'vitest'; +import { buildWwwAuthenticateHeader, sendUnauthorized } from '../challenge.js'; + +const RESOURCE_METADATA_URL = + 'https://mcp.example.com/.well-known/oauth-protected-resource'; + +describe('buildWwwAuthenticateHeader', () => { + it('includes the resource_metadata pointer', () => { + expect(buildWwwAuthenticateHeader({ resourceMetadataUrl: RESOURCE_METADATA_URL })).toBe( + `Bearer resource_metadata="${RESOURCE_METADATA_URL}"`, + ); + }); + + it('appends error and error_description when provided', () => { + const header = buildWwwAuthenticateHeader({ + resourceMetadataUrl: RESOURCE_METADATA_URL, + error: 'invalid_token', + errorDescription: 'The access token expired', + }); + expect(header).toBe( + `Bearer resource_metadata="${RESOURCE_METADATA_URL}", error="invalid_token", error_description="The access token expired"`, + ); + }); + + it('omits error_description when error is absent (RFC 6750 §3)', () => { + const header = buildWwwAuthenticateHeader({ + resourceMetadataUrl: RESOURCE_METADATA_URL, + errorDescription: 'orphaned description', + }); + expect(header).toBe(`Bearer resource_metadata="${RESOURCE_METADATA_URL}"`); + expect(header).not.toContain('error_description'); + }); + + it('escapes quotes and backslashes in quoted-string values', () => { + const header = buildWwwAuthenticateHeader({ + resourceMetadataUrl: RESOURCE_METADATA_URL, + error: 'invalid_token', + errorDescription: 'has "quote" and \\ backslash', + }); + expect(header).toContain('error_description="has \\"quote\\" and \\\\ backslash"'); + }); +}); + +describe('sendUnauthorized', () => { + function mockRes() { + const res = { + setHeader: vi.fn(() => res), + writeHead: vi.fn(() => res), + end: vi.fn(() => res), + }; + return res as unknown as ServerResponse & { + setHeader: ReturnType; + writeHead: ReturnType; + end: ReturnType; + }; + } + + it('sets WWW-Authenticate and writes a 401 JSON body', () => { + const res = mockRes(); + sendUnauthorized(res, { resourceMetadataUrl: RESOURCE_METADATA_URL }); + + expect(res.setHeader).toHaveBeenCalledWith( + 'WWW-Authenticate', + `Bearer resource_metadata="${RESOURCE_METADATA_URL}"`, + ); + expect(res.writeHead).toHaveBeenCalledWith(401, { 'Content-Type': 'application/json' }); + const body = JSON.parse(res.end.mock.calls[0][0] as string); + expect(body.error).toBe('unauthorized'); + expect(typeof body.error_description).toBe('string'); + }); + + it('propagates a specific error code into the body and header', () => { + const res = mockRes(); + sendUnauthorized(res, { + resourceMetadataUrl: RESOURCE_METADATA_URL, + error: 'invalid_token', + errorDescription: 'expired', + }); + expect((res.setHeader.mock.calls[0][1] as string)).toContain('error="invalid_token"'); + const body = JSON.parse(res.end.mock.calls[0][0] as string); + expect(body.error).toBe('invalid_token'); + expect(body.error_description).toBe('expired'); + }); +}); diff --git a/packages/mcp-server/src/oauth/__tests__/metadata.test.ts b/packages/mcp-server/src/oauth/__tests__/metadata.test.ts new file mode 100644 index 000000000..38bdf078f --- /dev/null +++ b/packages/mcp-server/src/oauth/__tests__/metadata.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import { + buildProtectedResourceMetadata, + PROTECTED_RESOURCE_METADATA_PATH, + protectedResourceMetadataUrl, +} from '../metadata.js'; + +describe('buildProtectedResourceMetadata', () => { + it('sets resource to the MCP public base URL exactly', () => { + const doc = buildProtectedResourceMetadata({ + resource: 'https://mcp.example.com', + authorizationServers: ['https://tenant.auth0.com/'], + }); + expect(doc.resource).toBe('https://mcp.example.com'); + }); + + it('lists the authorization servers', () => { + const doc = buildProtectedResourceMetadata({ + resource: 'https://mcp.example.com', + authorizationServers: ['https://tenant.auth0.com/'], + }); + expect(doc.authorization_servers).toEqual(['https://tenant.auth0.com/']); + }); + + it('declares header-only bearer methods (no query-param tokens)', () => { + const doc = buildProtectedResourceMetadata({ + resource: 'https://mcp.example.com', + authorizationServers: ['https://tenant.auth0.com/'], + }); + expect(doc.bearer_methods_supported).toEqual(['header']); + }); + + it('produces a stable document shape', () => { + const doc = buildProtectedResourceMetadata({ + resource: 'https://mcp.example.com', + authorizationServers: ['https://tenant.auth0.com/'], + }); + expect(Object.keys(doc).sort()).toEqual([ + 'authorization_servers', + 'bearer_methods_supported', + 'resource', + ]); + }); + + it('copies the authorization servers (no shared reference)', () => { + const servers = ['https://tenant.auth0.com/']; + const doc = buildProtectedResourceMetadata({ resource: 'https://x', authorizationServers: servers }); + expect(doc.authorization_servers).not.toBe(servers); + }); +}); + +describe('protectedResourceMetadataUrl', () => { + it('joins the public base URL with the well-known path', () => { + expect(protectedResourceMetadataUrl('https://mcp.example.com')).toBe( + `https://mcp.example.com${PROTECTED_RESOURCE_METADATA_PATH}`, + ); + }); + + it('normalizes a trailing slash on the base URL', () => { + expect(protectedResourceMetadataUrl('https://mcp.example.com/')).toBe( + `https://mcp.example.com${PROTECTED_RESOURCE_METADATA_PATH}`, + ); + }); +}); diff --git a/packages/mcp-server/src/oauth/challenge.ts b/packages/mcp-server/src/oauth/challenge.ts new file mode 100644 index 000000000..63e0b5c50 --- /dev/null +++ b/packages/mcp-server/src/oauth/challenge.ts @@ -0,0 +1,53 @@ +import type { ServerResponse } from 'node:http'; + +/** + * Standardized 401 challenge for MCP auth discovery. + * + * When a request is unauthenticated, the server must respond with `401` and a + * `WWW-Authenticate: Bearer` header carrying a `resource_metadata` pointer to + * the protected-resource metadata document (RFC 9728 §5.1 / spec §6.2). Claude + * follows that pointer to begin the OAuth flow. + * + * This is a transport-level challenge — never a JSON-RPC/tool-level error. + */ +export interface UnauthorizedOptions { + /** Absolute URL of the protected-resource metadata document. */ + resourceMetadataUrl: string; + /** Optional RFC 6750 error code (e.g. `invalid_token`). */ + error?: string; + /** Optional human-readable description (must be safe for end users). */ + errorDescription?: string; +} + +/** Escape `"` and `\` so a value is a valid RFC 7235 quoted-string. */ +function quote(value: string): string { + return value.replace(/["\\]/g, '\\$&'); +} + +/** Build the `WWW-Authenticate` header value for a bearer challenge. */ +export function buildWwwAuthenticateHeader(options: UnauthorizedOptions): string { + const params = [`resource_metadata="${quote(options.resourceMetadataUrl)}"`]; + // Per RFC 6750 §3, error_description is only meaningful alongside error. + if (options.error) { + params.push(`error="${quote(options.error)}"`); + if (options.errorDescription) { + params.push(`error_description="${quote(options.errorDescription)}"`); + } + } + return `Bearer ${params.join(', ')}`; +} + +/** + * Write a standards-compliant 401 challenge to the response, including the + * `WWW-Authenticate` header with the `resource_metadata` pointer. + */ +export function sendUnauthorized(res: ServerResponse, options: UnauthorizedOptions): void { + res.setHeader('WWW-Authenticate', buildWwwAuthenticateHeader(options)); + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + error: options.error ?? 'unauthorized', + error_description: options.errorDescription ?? 'Authentication required', + }), + ); +} diff --git a/packages/mcp-server/src/oauth/metadata.ts b/packages/mcp-server/src/oauth/metadata.ts new file mode 100644 index 000000000..2cd568eaa --- /dev/null +++ b/packages/mcp-server/src/oauth/metadata.ts @@ -0,0 +1,46 @@ +/** + * OAuth 2.0 Protected Resource Metadata (RFC 9728). + * + * Claude clients discover how to authenticate to this MCP server by fetching + * the well-known document served here. The document points at the Auth0 + * authorization server(s) and declares that bearer tokens are accepted via the + * `Authorization` header only (never query params — see spec §6.5). + */ + +/** Well-known path for the protected resource metadata document. */ +export const PROTECTED_RESOURCE_METADATA_PATH = '/.well-known/oauth-protected-resource'; + +/** Inputs for building the metadata document. Kept explicit for testability. */ +export interface ProtectedResourceMetadataConfig { + /** Canonical resource identifier — the MCP server's public base URL. */ + resource: string; + /** Authorization server issuer URLs (Auth0). */ + authorizationServers: readonly string[]; +} + +/** Shape of the served metadata document (RFC 9728 subset). */ +export interface ProtectedResourceMetadata { + resource: string; + authorization_servers: string[]; + bearer_methods_supported: string[]; +} + +/** + * Build the protected resource metadata document. Pure and config-driven — no + * environment-specific values are hardcoded. + */ +export function buildProtectedResourceMetadata( + config: ProtectedResourceMetadataConfig, +): ProtectedResourceMetadata { + return { + resource: config.resource, + authorization_servers: [...config.authorizationServers], + // Tokens are accepted only in the Authorization header, never query params. + bearer_methods_supported: ['header'], + }; +} + +/** Absolute URL of the metadata document, given the MCP public base URL. */ +export function protectedResourceMetadataUrl(publicBaseUrl: string): string { + return `${publicBaseUrl.replace(/\/+$/, '')}${PROTECTED_RESOURCE_METADATA_PATH}`; +} diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts new file mode 100644 index 000000000..e86d30164 --- /dev/null +++ b/packages/mcp-server/src/server.ts @@ -0,0 +1,182 @@ +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 { 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); + }, + // 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 { + const handler = 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/version.ts b/packages/mcp-server/src/version.ts new file mode 100644 index 000000000..f9969ff1f --- /dev/null +++ b/packages/mcp-server/src/version.ts @@ -0,0 +1,29 @@ +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** Canonical service name used in logs, health, and MCP server info. */ +export const SERVICE_NAME = '@accounter/mcp-server'; + +let cachedVersion: string | undefined; + +/** + * Resolve the package version at runtime without importing outside `rootDir`. + * Works from both `src/` (dev via tsx) and `dist/` (built), since each is one + * level below the package root. The result is cached — the version cannot + * change while the process runs, so we avoid a synchronous disk read on every + * `/health` request. + */ +export function getServiceVersion(): string { + if (cachedVersion !== undefined) { + return cachedVersion; + } + try { + const packageJsonPath = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'); + const parsed = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { version?: string }; + cachedVersion = parsed.version ?? '0.0.0'; + } catch { + cachedVersion = '0.0.0'; + } + return cachedVersion; +} diff --git a/packages/mcp-server/tsconfig.json b/packages/mcp-server/tsconfig.json new file mode 100644 index 000000000..e306bfcac --- /dev/null +++ b/packages/mcp-server/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "module": "ES2022", + "target": "ES2022", + "strict": true, + "sourceMap": true, + "declaration": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/mcp-server/vitest.config.ts b/packages/mcp-server/vitest.config.ts new file mode 100644 index 000000000..0a2995a8d --- /dev/null +++ b/packages/mcp-server/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; + +// Package-local Vitest config so `yarn workspace @accounter/mcp-server test` +// runs only this package's tests in isolation. The repo-root config still +// discovers these files via its `unit` project glob when running `yarn test`. +export default defineConfig({ + test: { + globals: true, + include: ['src/**/*.test.ts'], + }, +}); diff --git a/yarn.lock b/yarn.lock index 71ed46fae..784ee5ab0 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"