Add support for hosting MCP servers in ECS#55
Open
mike-parkhill wants to merge 31 commits into
Open
Conversation
…ing a BBS credential
Reusable workflows do not inherit secrets automatically — the caller must pass secrets: inherit. Both docker-publish-wallet.yml and docker-publish.yml were calling the reusable workflow without this, causing DOCKER_HUB_USERNAME and DOCKER_HUB_ACCESS_TOKEN to arrive as empty strings. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds extractBearerToken, verifyJWT (ES256), deriveWalletKey, and resolveAuthContext to a new @truvera/mcp-shared/auth export path. Pins jose at 6.2.3 for JWT verification. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- ServerConfig gains optional toolHandlerFactory for per-session handlers
- bootstrapMCPServer passes AuthContext into createServer; stdio uses {mode:'none'}
- startHTTPTransport resolves auth on initialize requests, rejects with 401/403
on AuthError, and awaits the now-async serverFactory(context)
- AuthConfig/AuthContext re-exported from server/types for consumer convenience
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- scripts/generate-keypair.js: generates ES256 keypair, prints private key (for Secrets Manager) and public key (for ECS MCP_JWT_PUBLIC_KEY) - scripts/mint-jwt.js: mints a tenant JWT from a private key held in AWS Secrets Manager; supports --profile for dev/test/prod AWS profiles, --expires-in for configurable token lifetime - admin:generate-keypair and admin:mint-jwt npm script aliases - README: documents MCP_JWT_PUBLIC_KEY / MCP_MASTER_SECRET env vars and full admin workflow for one-time setup and tenant provisioning Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- WalletClientPool: lazy-initialises one WalletClient per SQLite path, promise-based deduplication for concurrent session starts, retryable on init failure, shutdownAll() for graceful drain - index.ts: replaces static single-wallet init with toolHandlerFactory; per-session factory resolves tenant db path from AuthContext (jwt → /data/wallets/<sub>, none → WALLET_DB_PATH), creates per-tenant DIDClient/CredentialClient/MessageClient/DelegationClient/AgentCardClient, tracks MessageClients for clean SIGTERM/SIGINT shutdown - MCP_AUTH_MODE=jwt requires MCP_JWT_PUBLIC_KEY; defaults to none for local dev with no config changes needed Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- mcp-shared/src/auth/index.test.ts: 21 tests covering extractBearerToken, verifyJWT (including expired/wrong-key/no-sub/bad-pem), deriveWalletKey, and resolveAuthContext across all three auth modes - wallet-server/src/tests/unit/wallet-client-pool.test.ts: 8 tests covering instance caching, concurrent-init deduplication, constructor arg forwarding, retry-after-failure, shutdownAll drain, pool clearing, and partial-failure resilience in shutdown - wallet-server package.json: adds wallet-client-pool test to npm test script Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
In HTTP mode the caller's bearer token IS their Truvera API key. A per-session TruveraClient is created from context.apiKey inside toolHandlerFactory, so TRUVERA_API_KEY is no longer required at startup for HTTP deployments. stdio mode is unchanged: TRUVERA_API_KEY still required from env for local dev / CLI usage. AP2 schema initialisation remains a one-time startup fetch; only the per-session client wiring (AP2Client, OpenIdClient) is done inside the factory. Tests: 4 new unit tests for TruveraClient per-session key wiring confirm the bearer token is forwarded correctly and that distinct sessions use distinct keys. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Creates a self-contained Terraform configuration for deploying both MCP
services to a fresh AWS account (one account per environment).
Infrastructure created:
- ECS Fargate cluster (truvera-mcp-{env}) with container insights
- ALB with HTTPS listener (TLS 1.3), HTTP→HTTPS redirect, host-based
routing rules routing each hostname to its service
- truvera-api-mcp: stateless, no volumes, passthrough auth — bearer
token forwarded directly to the Truvera REST API
- wallet-mcp: EFS-backed SQLite, JWT auth, desired_count=1 enforced
by service config and documented constraint (SQLite single-writer)
- EFS file system with per-AZ mount targets and an access point that
pins the container to /wallets with uid/gid 1000
- Separate security groups per service (ALB, truvera-api task,
wallet-server task, EFS) for clean isolation
- IAM execution role (shared) with Secrets Manager read for wallet
secret injection at startup; per-service task roles
- CloudWatch log groups (30-day retention)
MCP_JWT_PUBLIC_KEY and WALLET_MASTER_KEY are injected from a
Secrets Manager secret at task startup — the secret must be created
manually before first apply (see terraform.tfvars.example).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Removes the assumption of a pre-existing VPC. Terraform now provisions all networking resources needed for a fresh AWS account: - VPC (10.0.0.0/16 by default, overridable via vpc_cidr) - Internet Gateway - 2 public subnets across two AZs (cidrsubnet-derived, one per AZ) - Public route table with 0.0.0.0/0 → IGW - Route table associations All references to var.vpc_id / var.subnet_ids replaced with aws_vpc.main.id and aws_subnet.public[*].id respectively. Outputs now include vpc_id and public_subnet_ids. terraform.tfvars.example updated to reflect that networking is fully managed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- backend.tf: S3 remote state with DynamoDB locking; includes the bootstrap commands to create the bucket and lock table (once per account before terraform init) - environments/staging.tfvars, prod.tfvars, test.tfvars: one file per environment with appropriate hostnames and cheqd_network; ARN placeholders to fill in after ACM cert and Secrets Manager secret are created - .gitignore: environments/ is committed (config, not secrets); terraform.tfvars (local overrides) stays ignored Workflow: AWS_PROFILE=<env> terraform init, then terraform apply -var-file=environments/<env>.tfvars Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
workflow_dispatch runs now tag the image with the sanitized branch name (e.g. DCKA-5551-hmac-auth) instead of the sequential run number, so staging.tfvars can reference a stable, meaningful tag while iterating on a branch. An explicit tag input overrides the default. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ode 22+
esm v3.2.25 uses Function.prototype.apply on an internal that Node 22+
no longer exposes, crashing the wallet-server on startup with
"Function.prototype.apply was called on undefined".
The crash path: dist/index.js (ESM) → wallet-sdk-wasm/lib/services/blockchain
/service.js (CJS) → jsonld-signatures/node_modules/jsonld → documentLoaders/
node.js → require('@digitalbazaar/http-client') → index.js: require = require
('esm')(module) → crash.
Fix: add http-client-stub.cjs — a self-contained CJS module that implements
the httpClient.get/post surface using native Node 22+ fetch — and register it
as a module-alias for @digitalbazaar/http-client, so module-alias intercepts
all require() calls to the package (including from deeply nested CJS modules)
before they reach the broken esm wrapper.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
…f sensitive information' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR adds an AWS ECS (Fargate) hosting model for the MCP servers and introduces request-level authentication support in the shared MCP HTTP transport so the services can run as multi-tenant (wallet via JWT) or per-request credential passthrough (truvera-api via API key).
Changes:
- Added Terraform configuration to deploy both MCP servers behind a single ALB, with EFS-backed persistence for the wallet server.
- Introduced
@truvera/mcp-shared/authand updated the shared HTTP transport + server bootstrap to resolve auth context per MCP session. - Updated
truvera-apiandwallet-serverto use per-session handler factories wired to the resolved auth context; expanded docs for hosted vs self-hosted usage.
Reviewed changes
Copilot reviewed 51 out of 54 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| WALLET_MCP_PLAN.md | Updates wallet server roadmap/status and documents JWT multi-tenant mode. |
| README.md | Adds hosted vs self-hosted guidance and documents per-client vs shared-key auth. |
| docs/claude-desktop-setup.md | Adds hosted connection instructions for Claude Desktop (API key + wallet token). |
| packages/mcp-shared/src/transport/http/index.ts | Resolves auth per initialize request and passes AuthContext into server factory. |
| packages/mcp-shared/src/server/types.ts | Adds auth config/context types + per-session handler factory option. |
| packages/mcp-shared/src/server/bootstrap.ts | Supports per-session handler factories and forwards auth config to HTTP transport. |
| packages/mcp-shared/src/auth/index.ts | Implements auth resolution (none, jwt, passthrough) + wallet-key derivation helper. |
| packages/mcp-shared/src/auth/index.test.ts | Adds unit tests covering auth parsing/verification and key derivation behavior. |
| packages/mcp-shared/README.md | Documents bootstrap/auth features and intended auth mode usage by apps. |
| packages/mcp-shared/package.json | Exposes ./auth entrypoint and adds jose dependency. |
| package-lock.json | Locks new dependencies (jose, AWS SDK clients for wallet admin scripts, etc.). |
| apps/wallet-server/src/index.ts | Switches to per-session handler factory with JWT auth + wallet pooling. |
| apps/wallet-server/src/wallet-client-pool.ts | Adds per-tenant WalletClient caching/deduplication layer. |
| apps/wallet-server/src/tests/unit/wallet-client-pool.test.ts | Adds unit coverage for wallet client pooling/deduplication/shutdown behavior. |
| apps/wallet-server/src/tests/integration/localstorage-shim.test.ts | Adjusts integration test import path for wallet-sdk storage shim behavior. |
| apps/wallet-server/src/build-info.ts | Bumps build metadata. |
| apps/wallet-server/scripts/mint-jwt.js | Adds admin script to mint tenant JWTs using Secrets Manager private key. |
| apps/wallet-server/scripts/generate-keypair.js | Adds keypair generation script for ES256 JWT auth. |
| apps/wallet-server/register-aliases.cjs | Extends module-alias wiring and adds stub for @digitalbazaar/http-client. |
| apps/wallet-server/http-client-stub.cjs | Introduces Node fetch-based CJS stub for @digitalbazaar/http-client. |
| apps/wallet-server/Dockerfile | Includes the new http-client stub in the runtime image. |
| apps/wallet-server/package.json | Adds admin scripts and extends unit test command list. |
| apps/wallet-server/jest.setup.js | Adds additional mocking (but currently duplicates an existing logger mock). |
| apps/wallet-server/.env.test | Adds wallet-server test env file (currently includes a live-looking API key). |
| apps/wallet-server/test-mcp.js | Adds an ad-hoc MCP client script for local manual testing. |
| apps/wallet-server/README.md | Updates wallet server status, tools, JWT auth docs, and known limitations. |
| apps/truvera-api/src/index.ts | Adds HTTP-mode passthrough auth + per-session handler factory; keeps stdio behavior. |
| apps/truvera-api/src/build-info.ts | Bumps build metadata. |
| apps/truvera-api/README.md | Documents HTTP auth behavior (shared fallback key vs per-client headers). |
| apps/truvera-api/tests/unit/truvera-client.test.ts | Adds unit tests verifying per-session API key usage in TruveraClient. |
| apps/truvera-api/src/features/ap2/schemas/SCHEMAS.md | Updates AP2 schema documentation details/requirements. |
| apps/truvera-api/src/features/ap2/README.md | Clarifies optional subject_did behavior and adds test structure notes. |
| .gitignore | Ignores local Terraform state and overrides. |
| terraform/main.tf | Adds Terraform provider config for AWS. |
| terraform/backend.tf | Configures S3 backend for remote state. |
| terraform/variables.tf | Declares deployment variables (images, hostnames, sizing, secrets). |
| terraform/terraform.tfvars.example | Provides an example tfvars template with setup instructions. |
| terraform/vpc.tf | Provisions a VPC, IGW, public subnets, and public route table. |
| terraform/security_groups.tf | Adds ALB/task/EFS security groups with ALB-only ingress to tasks. |
| terraform/alb.tf | Configures an ALB with HTTPS listener + host-based routing to both services. |
| terraform/efs.tf | Provisions EFS + access point for wallet SQLite persistence (single-writer). |
| terraform/ecs.tf | Defines ECS cluster, task defs, services, EFS mount, and secrets injection. |
| terraform/iam.tf | Adds execution/task roles and Secrets Manager access policies. |
| terraform/logs.tf | Adds CloudWatch log groups for both services. |
| terraform/outputs.tf | Outputs core infra/service identifiers and ALB DNS/zone info. |
| terraform/environments/test.tfvars | Adds a test environment tfvars stub. |
| terraform/environments/staging.tfvars | Adds staging tfvars with concrete values. |
| terraform/environments/prod.tfvars | Adds prod environment tfvars stub. |
| terraform/.terraform.lock.hcl | Locks Terraform provider versions/hashes. |
| .github/workflows/ci.yml | Scopes API CI to path filters and simplifies the workflow graph. |
| .github/workflows/docker-publish-reusable.yml | Adds optional explicit Docker tag support. |
| .github/workflows/docker-publish-api.yml | Plumbs through optional explicit Docker tag input. |
| .github/workflows/docker-publish-wallet.yml | Plumbs through optional explicit Docker tag input. |
| .github/copilot-instructions.md | Updates repo instructions to include wallet-server and new scripts/CI pattern. |
Files not reviewed (1)
- terraform/.terraform.lock.hcl: Generated file
Comment on lines
+1
to
+3
| # Truvera API Configuration | ||
| TRUVERA_API_KEY=eyJzY29wZXMiOlsidGVzdCIsImFsbCJdLCJzdWIiOiIyMTYyNiIsInNlbGVjdGVkVGVhbUlkIjowLCJjcmVhdG9ySWQiOiIyMyIsImZtdCI6MSwiaWF0IjoxNzczNzcwNDU2LCJleHAiOjQ4NTMwNjY0NTZ9.K9TIa50Saw4SC63PGPWVWQw21dxq06awpL6vhhqs2igmWOxiWexNxF3f8hRmebEAchEhUKeBpq07dbU2fHEZAw | ||
| TRUVERA_API_ENDPOINT=https://api-testnet.truvera.io |
Comment on lines
+16
to
+21
| // wallet-sdk-wasm's storageService calls global.localStorage for DID resolution | ||
| // caching during BBS+ proof generation. Node.js has no native localStorage, so | ||
| // we use node-localstorage backed by the same /data volume as the wallet DB. | ||
| const WALLET_DB_PATH_RESOLVED = process.env.WALLET_DB_PATH || "/data/wallet-db"; | ||
| const _lsPath = `${WALLET_DB_PATH_RESOLVED}-localstorage`; | ||
| (globalThis as any).localStorage = new LocalStorage(_lsPath); |
Comment on lines
+9
to
+11
| * Concurrency across processes: SQLite is single-writer. This pool assumes a | ||
| * single running process per storage volume, which is enforced by using EBS | ||
| * (single-attach) in ECS. Do not use EFS for wallet storage. |
Comment on lines
+35
to
+45
| // The wallet SDK can emit late debug logs from background status updates. | ||
| // Keep integration tests deterministic by silencing data-store logger output. | ||
| jest.mock('@docknetwork/wallet-sdk-data-store/src/logger', () => ({ | ||
| logger: { | ||
| debug: jest.fn(), | ||
| performance: jest.fn(), | ||
| error: jest.fn(), | ||
| warn: jest.fn(), | ||
| info: jest.fn(), | ||
| }, | ||
| })); |
Comment on lines
+80
to
+84
| Statement = [{ | ||
| Effect = "Allow" | ||
| Action = ["secretsmanager:GetSecretValue", "kms:Decrypt"] | ||
| Resource = [var.wallet_secret_arn] | ||
| }] |
Comment on lines
34
to
+38
| function normalizeDIDDocument(doc: any): any { | ||
| if (!doc || typeof doc !== "object") return doc; | ||
| // Clone before mutating — the resolver caches documents in node-localstorage and | ||
| // deserialises them on each cache hit, so mutating in place would cause a fresh | ||
| // copy to be processed again on the next resolution of the same DID, accumulating | ||
| // duplicate entries in verificationMethod. | ||
| const result = structuredClone(doc); | ||
| const extra: any[] = []; | ||
| for (const prop of ["assertionMethod", "authentication", "capabilityInvocation", "capabilityDelegation"]) { | ||
| if (!Array.isArray(result[prop])) continue; | ||
| result[prop] = result[prop].map((entry: any) => { | ||
| if (!Array.isArray(doc[prop])) continue; |
Comment on lines
+104
to
+106
| // Track active MessageClients so we can stop background timers on shutdown | ||
| const activeMessageClients = new Set<MessageClient>(); | ||
|
|
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 54 out of 57 changed files in this pull request and generated 3 comments.
Files not reviewed (1)
- terraform/.terraform.lock.hcl: Generated file
Comments suppressed due to low confidence (2)
apps/wallet-server/src/index.ts:38
- normalizeDIDDocument mutates the DID document in-place (including appending to verificationMethod). If the underlying resolver caches the resolved document object, repeated resolves can accumulate duplicate verificationMethod entries and/or pollute the shared DID cache. Clone before normalizing so the cached value remains stable.
function normalizeDIDDocument(doc: any): any {
if (!doc || typeof doc !== "object") return doc;
const extra: any[] = [];
for (const prop of ["assertionMethod", "authentication", "capabilityInvocation", "capabilityDelegation"]) {
if (!Array.isArray(doc[prop])) continue;
apps/wallet-server/package.json:40
- These AWS SDK v3 devDependencies require Node.js >=20 (per package-lock), but this package declares engines node>=18. This can break installs/running admin scripts on Node 18 even though the repo docs currently say Node 18+.
"devDependencies": {
"@aws-sdk/client-secrets-manager": "^3.1082.0",
"@aws-sdk/credential-providers": "^3.1082.0",
"@babel/core": "^7.26.0",
Comment on lines
+27
to
+37
| async function readJsonBody(req: IncomingMessage): Promise<unknown> { | ||
| const data = await new Promise<string>((resolve, reject) => { | ||
| let chunks = ""; | ||
| req.on("data", (chunk: Buffer) => { | ||
| chunks += chunk; | ||
| }); | ||
| req.on("end", () => resolve(chunks)); | ||
| req.on("error", reject); | ||
| }); | ||
| return data ? JSON.parse(data) : undefined; | ||
| } |
Comment on lines
306
to
+310
| } else if (!sessionId && initializeRequest) { | ||
| // New session: create transport and server instance | ||
| // New session: resolve auth then create transport and server instance | ||
| let authContext; | ||
| try { | ||
| authContext = await resolveAuthContext(req, authConfig ?? { mode: "none" }); |
Comment on lines
+1
to
+4
| variable "aws_region" { | ||
| description = "AWS region to deploy into" | ||
| default = "us-west-1" | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
** Uses the user's Truvera API key for the API server
** Uses a generated wallet token for the wallet server (generated by admin script) - NOTE: doesn't support revocation yet