diff --git a/servers/sh-mcp-finance/README.md b/servers/sh-mcp-finance/README.md new file mode 100644 index 0000000..f8c2a24 --- /dev/null +++ b/servers/sh-mcp-finance/README.md @@ -0,0 +1,81 @@ +# sh-mcp-finance + +Finance-tier Sea Haven MCP server. Exposes the **finance** tool registry +(qbo `search_vendors`, payments `lookup_payment_by_*` — see `docs/design.md §3`) +over the same two interfaces as `sh-mcp-ops` (MCP Streamable HTTP + OpenAPI 3.1), +through the same shared dispatch path. + +Finance is **sensitive, read-only, and fully audited**: every tool call emits a +structured audit record and the response is redacted on egress (bank account / +routing / card / SSN / tax-id masked) before it leaves the server +(`docs/design.md §2.5, §7.3`). + +## Run locally (no AWS, no Cognito) + +```bash +npm install +npm run build + +SH_MCP_ENV=local PORT=8082 npm run start -w @sh-mcp/server-finance +# or: SH_MCP_ENV=local npm run dev -w @sh-mcp/server-finance +``` + +### Endpoints + +Identical shape to `sh-mcp-ops`: `GET /healthz`, `GET /openapi.json` (both +unauthenticated), `POST /mcp`, and `POST /tools/:name` (both authenticated). + +### Dev bearer tokens (local only) + +| Token | Identity | Scopes | +| ------------------- | -------------------------- | ------------------------------------------- | +| `dev-finance` | accounting@seahavenind.com | `ops:read`, `finance:read` | +| `dev-finance-admin` | adam@seahavenind.com | `ops:read`, `finance:read`, `finance:admin` | + +Ops-tier tokens (`dev-ops-only`, `dev-assistant`) are **rejected** by this server +(audience binding). + +### Sample curl — redaction on egress + +```bash +TOKEN=dev-finance + +curl -s -X POST localhost:8082/tools/lookup_payment_by_vendor \ + -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"vendor":"Harbor"}' | jq +# → bankAccountNumber / bankRoutingNumber / cardNumber are "[REDACTED]"; +# vendor, amount, invoice number are intact. +``` + +Each call writes one structured audit line to stdout (CloudWatch in Lambda): + +```json +{ + "kind": "audit", + "sub": "...", + "tool": "lookup_payment_by_vendor", + "argsHash": "", + "decision": "allow", + "result": "ok", + "ts": "..." +} +``` + +Args are **hashed, never logged raw** — secrets never reach the audit log. + +### MCP Inspector + +Point it at `http://localhost:8082/mcp` (Streamable HTTP) with +`Authorization: Bearer dev-finance`. + +## Environment + +Same as `sh-mcp-ops` plus `PAYMENTS_TABLE` (aws mode). Finance additionally +applies the **15-minute TTL ceiling** on `finance:*` tokens in `aws` mode +(`docs/design.md §2.5`). `aws` mode is not runtime-exercised in Phase 1. + +## CDK + +`cdk/app.ts` is a **synth-only** placeholder (no real IAM/Cognito/WAF) — the +finance least-privilege role and audit wiring land in a later phase behind the +mandatory IAM cross-review. diff --git a/servers/sh-mcp-finance/cdk.json b/servers/sh-mcp-finance/cdk.json new file mode 100644 index 0000000..9e92cc2 --- /dev/null +++ b/servers/sh-mcp-finance/cdk.json @@ -0,0 +1,7 @@ +{ + "app": "tsx cdk/app.ts", + "output": "cdk.out", + "context": { + "@aws-cdk/core:newStyleStackSynthesis": true + } +} diff --git a/servers/sh-mcp-finance/cdk/app.ts b/servers/sh-mcp-finance/cdk/app.ts new file mode 100644 index 0000000..44c161d --- /dev/null +++ b/servers/sh-mcp-finance/cdk/app.ts @@ -0,0 +1,30 @@ +/** + * sh-mcp-finance — synth-only CDK app (build-plan §6). + * + * Exists ONLY so the CI `cdk synth` gate has a valid app to synthesize. Defines + * NO real IAM/Cognito/API-Gateway/WAF resources (those need the mandatory human + * IAM cross-review). The real stack — including the finance least-privilege role + * and audit wiring (design.md §2.5) — is a later phase. + * + * TODO(phase-2): real stack — gated on Cognito + IAM cross-review (design.md §8). + */ + +import { App, Stack, CfnOutput, type StackProps } from 'aws-cdk-lib'; +import type { Construct } from 'constructs'; + +class ShMcpFinanceStack extends Stack { + constructor(scope: Construct, id: string, props?: StackProps) { + super(scope, id, props); + + new CfnOutput(this, 'PlatformTier', { + value: 'finance', + description: 'sh-mcp-finance trust tier (synth-only placeholder; design.md §3).', + }); + } +} + +const app = new App(); +new ShMcpFinanceStack(app, 'sh-mcp-finance', { + description: 'Sea Haven MCP finance-tier server (synth-only stub — no real infra yet).', +}); +app.synth(); diff --git a/servers/sh-mcp-finance/package.json b/servers/sh-mcp-finance/package.json new file mode 100644 index 0000000..ee18c27 --- /dev/null +++ b/servers/sh-mcp-finance/package.json @@ -0,0 +1,36 @@ +{ + "name": "@sh-mcp/server-finance", + "version": "0.1.0", + "description": "Sea Haven MCP finance-tier server — MCP (Streamable HTTP) + OpenAPI 3.1 over the finance tool registry, fully audited", + "license": "UNLICENSED", + "private": true, + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "dev": "tsx watch src/index.ts", + "start": "node dist/index.js", + "build": "tsc --project tsconfig.json", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "synth": "cdk synth >/dev/null" + }, + "engines": { + "node": ">=24" + }, + "dependencies": { + "@sh-mcp/payments": "*", + "@sh-mcp/qbo": "*", + "@sh-mcp/shared": "*", + "express": "5.2.1" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "aws-cdk-lib": "2.260.0", + "constructs": "10.6.0", + "tsx": "4.22.4", + "typescript": "^5.5.0", + "vitest": "^2.0.0" + } +} diff --git a/servers/sh-mcp-finance/src/app.ts b/servers/sh-mcp-finance/src/app.ts new file mode 100644 index 0000000..1b2f712 --- /dev/null +++ b/servers/sh-mcp-finance/src/app.ts @@ -0,0 +1,57 @@ +/** + * sh-mcp-finance application assembly. + * + * Same shared host as ops, but the registry holds finance-tier tools — so every + * call is audited and redacted on egress by the shared dispatcher (design.md + * §2.5, §7.3). Exported separately from `index.ts` for tests. + */ + +import { + ConsoleAuditLogger, + InMemoryRateLimiter, + createApp, + type DispatchDeps, +} from '@sh-mcp/shared'; +import type { Express } from 'express'; + +import { buildFinanceRegistry } from './registry.js'; +import { buildFinanceAuthProvider } from './auth.js'; +import { loadFinanceConfig, type FinanceConfig } from './config.js'; + +export interface BuildAppResult { + app: Express; + config: FinanceConfig; +} + +export function buildFinanceApp(configOverride?: FinanceConfig): BuildAppResult { + const config = configOverride ?? loadFinanceConfig(); + const registry = buildFinanceRegistry(config); + const authProvider = buildFinanceAuthProvider(config); + + const deps: DispatchDeps = { + auditLogger: new ConsoleAuditLogger(), + // Finance is read-only and lower-volume; cap tighter than ops. + rateLimiter: new InMemoryRateLimiter({ + sessionCap: 100, + perToolLimit: 30, + windowMs: 60_000, + }), + }; + + const app = createApp({ + registry, + authProvider, + deps, + mcpInfo: { name: 'sh-mcp-finance', version: '0.1.0' }, + openApi: { + info: { + title: 'Sea Haven MCP — Finance', + version: '0.1.0', + description: 'Finance-tier tools (sensitive, read-only, audited). design.md §3.', + }, + servers: [{ url: `http://localhost:${config.port}`, description: 'local' }], + }, + }); + + return { app, config }; +} diff --git a/servers/sh-mcp-finance/src/auth.ts b/servers/sh-mcp-finance/src/auth.ts new file mode 100644 index 0000000..f85b7c9 --- /dev/null +++ b/servers/sh-mcp-finance/src/auth.ts @@ -0,0 +1,31 @@ +/** + * sh-mcp-finance AuthProvider selection. + * + * `aws` → CognitoAuthProvider with the finance TTL ceiling + finance scope + * prefix (design.md §2.5). + * `local` → LocalAuthProvider (dev bearer tokens; refuses to build unless + * SH_MCP_ENV=local). + */ + +import { + CognitoAuthProvider, + LocalAuthProvider, + defaultLocalPrincipals, + type AuthProvider, +} from '@sh-mcp/shared'; + +import type { FinanceConfig } from './config.js'; + +export function buildFinanceAuthProvider(config: FinanceConfig): AuthProvider { + if (config.env === 'aws') { + if (!config.cognito) { + throw new Error('Cognito config missing in aws mode.'); + } + return new CognitoAuthProvider(config.cognito); + } + return new LocalAuthProvider({ + audience: config.audience, + principals: defaultLocalPrincipals(), + env: 'local', + }); +} diff --git a/servers/sh-mcp-finance/src/config.ts b/servers/sh-mcp-finance/src/config.ts new file mode 100644 index 0000000..6724187 --- /dev/null +++ b/servers/sh-mcp-finance/src/config.ts @@ -0,0 +1,66 @@ +/** + * sh-mcp-finance configuration — read entirely from the environment. + * + * Like ops, nothing is hardcoded (build-plan §7). Finance additionally applies + * the 15-minute TTL ceiling on finance-scoped tokens (design.md §2.5). + */ + +import { cognitoIssuer, cognitoJwks, type CognitoAuthConfig } from '@sh-mcp/shared'; + +export type Env = 'local' | 'aws'; + +export interface FinanceConfig { + env: Env; + port: number; + audience: string; + cognito?: CognitoAuthConfig; + paymentsTable?: string; +} + +const AUDIENCE = 'sh-mcp-finance'; +const SCOPE_PREFIX = 'sh-mcp-finance'; +/** design.md §2.5: finance:* tokens must be short-lived (≤ 15 min). */ +const FINANCE_TTL_SECONDS = 15 * 60; + +function readEnv(name: string): string | undefined { + const v = process.env[name]; + return v !== undefined && v.length > 0 ? v : undefined; +} + +export function loadFinanceConfig(): FinanceConfig { + const rawEnv = readEnv('SH_MCP_ENV') ?? 'local'; + if (rawEnv !== 'local' && rawEnv !== 'aws') { + throw new Error(`SH_MCP_ENV must be "local" or "aws" (got "${rawEnv}").`); + } + const env = rawEnv; + const port = Number(readEnv('PORT') ?? '8082'); + + const base: FinanceConfig = { env, port, audience: AUDIENCE }; + + if (env === 'aws') { + const region = readEnv('AWS_REGION') ?? 'us-east-1'; + const userPoolId = required('COGNITO_USER_POOL_ID'); + const allowedClientIds = required('COGNITO_ALLOWED_CLIENT_IDS') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + base.cognito = { + issuer: cognitoIssuer(region, userPoolId), + audience: AUDIENCE, + allowedClientIds, + scopePrefix: SCOPE_PREFIX, + jwks: cognitoJwks(region, userPoolId), + maxTtlSeconds: FINANCE_TTL_SECONDS, + ttlGuardedScopes: ['finance:read', 'finance:admin'], + }; + base.paymentsTable = readEnv('PAYMENTS_TABLE'); + } + + return base; +} + +function required(name: string): string { + const v = readEnv(name); + if (!v) throw new Error(`Missing required env var ${name} for SH_MCP_ENV=aws.`); + return v; +} diff --git a/servers/sh-mcp-finance/src/index.ts b/servers/sh-mcp-finance/src/index.ts new file mode 100644 index 0000000..46944fa --- /dev/null +++ b/servers/sh-mcp-finance/src/index.ts @@ -0,0 +1,39 @@ +/** + * sh-mcp-finance entrypoint. The only place a listener is created + * (build-plan §2.1). A Lambda `handler` placeholder is exported for a future + * phase but is not depended on here. + */ + +import { buildFinanceApp } from './app.js'; + +export { buildFinanceApp } from './app.js'; + +function main(): void { + const { app, config } = buildFinanceApp(); + app.listen(config.port, () => { + console.log( + JSON.stringify({ + msg: 'sh-mcp-finance listening', + env: config.env, + port: config.port, + endpoints: ['/mcp', '/openapi.json', 'POST /tools/:name', '/healthz'], + }), + ); + }); +} + +/** Placeholder Lambda handler for a future phase — intentionally throws. */ +export function handler(): never { + throw new Error('Lambda handler is not implemented in Phase 1; run the HTTP server.'); +} + +const invokedDirectly = + process.argv[1] !== undefined && import.meta.url === `file://${process.argv[1]}`; +if (invokedDirectly) { + try { + main(); + } catch (err) { + console.error('Failed to start sh-mcp-finance:', err); + process.exit(1); + } +} diff --git a/servers/sh-mcp-finance/src/registry.ts b/servers/sh-mcp-finance/src/registry.ts new file mode 100644 index 0000000..c3a4428 --- /dev/null +++ b/servers/sh-mcp-finance/src/registry.ts @@ -0,0 +1,46 @@ +/** + * sh-mcp-finance tool registry — composition root. + * + * Registers exactly the finance-tier tools (design.md §3): qbo (search_vendors) + * and payments (lookup_payment_by_*). Every finance tool call is audited and + * redacted on egress by the shared dispatcher (design.md §2.5, §7.3). + * + * As with ops, we call each factory with an explicit client rather than + * importing a pre-wired `tools` array, to keep import-time side-effect free + * (build-plan §7). + */ + +import { ToolRegistry, type ToolDef } from '@sh-mcp/shared'; + +import { + makeSearchVendorsTool, + QboClientImpl, + type QboClientInterface, + InMemoryQboClient, +} from '@sh-mcp/qbo'; +import { + makePaymentsTools, + DynamoPaymentsClient, + type PaymentsClient, + InMemoryPaymentsClient, +} from '@sh-mcp/payments'; + +import type { FinanceConfig } from './config.js'; + +/** Build the finance registry for the given environment. */ +export function buildFinanceRegistry(config: FinanceConfig): ToolRegistry { + const local = config.env === 'local'; + + const qboClient: QboClientInterface = local ? new InMemoryQboClient() : new QboClientImpl(); + const paymentsClient: PaymentsClient = local + ? new InMemoryPaymentsClient() + : new DynamoPaymentsClient(config.paymentsTable); + + const registry = new ToolRegistry(); + const all: ToolDef[] = [ + makeSearchVendorsTool(qboClient), + ...makePaymentsTools(paymentsClient), + ].map((t) => t as ToolDef); + for (const tool of all) registry.register(tool); + return registry; +} diff --git a/servers/sh-mcp-finance/tsconfig.json b/servers/sh-mcp-finance/tsconfig.json new file mode 100644 index 0000000..2872b4f --- /dev/null +++ b/servers/sh-mcp-finance/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "declarationDir": "dist" + }, + "include": ["src"], + "references": [ + { "path": "../../packages/shared" }, + { "path": "../../packages/payments" }, + { "path": "../../packages/qbo" } + ] +} diff --git a/servers/sh-mcp-ops/README.md b/servers/sh-mcp-ops/README.md new file mode 100644 index 0000000..3d56def --- /dev/null +++ b/servers/sh-mcp-ops/README.md @@ -0,0 +1,97 @@ +# sh-mcp-ops + +Operations-tier Sea Haven MCP server. Exposes the **ops** tool registry +(internal-data, knowledge-base, google-maps, gmail, calendar, tasks, reminders — +see `docs/design.md §3`) over **two universal interfaces backed by one shared +dispatch path**: + +- **MCP** (Streamable HTTP) at `POST /mcp` — via `@modelcontextprotocol/sdk`. +- **OpenAPI 3.1** — full document at `GET /openapi.json`, plus one + `POST /tools/{tool-name}` endpoint per tool. + +All scope enforcement, audience binding, input validation, rate limiting, and +audit logging live in `@sh-mcp/shared` (`executeTool`) and are identical across +both interfaces (`docs/design.md §2.5`). + +## Run locally (no AWS, no Cognito) + +```bash +# from the repo root +npm install +npm run build # or: npx tsc -b + +SH_MCP_ENV=local PORT=8081 npm run start -w @sh-mcp/server-ops +# or live-reload: SH_MCP_ENV=local npm run dev -w @sh-mcp/server-ops +``` + +`SH_MCP_ENV=local` wires **in-memory dev clients** (seeded fake data, no network) +and the `LocalAuthProvider`, which maps dev bearer tokens to identities. The +local provider refuses to construct unless `SH_MCP_ENV=local`. + +### Endpoints + +| Method | Path | Auth | Notes | +| ------ | -------------------- | ---- | -------------------------------------- | +| GET | `/healthz` | no | `{ "status": "ok" }` | +| GET | `/openapi.json` | no | full OpenAPI 3.1 document | +| POST | `/mcp` (+GET/DELETE) | yes | MCP Streamable HTTP | +| POST | `/tools/:name` | yes | one-shot tool call, JSON in / JSON out | + +### Dev bearer tokens (local only) + +| Token | Identity | Scopes | +| --------------- | ------------------------ | -------------------------------------------- | +| `dev-ops-only` | ops-only@seahavenind.com | `ops:read`, `ops:tasks` | +| `dev-assistant` | lauren@seahavenind.com | + `gmail:self`, `calendar:self` | +| `dev-ops-admin` | adam@seahavenind.com | `ops:read`, `ops:tasks`, gmail/calendar self | + +Tokens minted for a different tier (e.g. `dev-finance`) are **rejected** by this +server (audience binding). + +### Sample curl + +```bash +TOKEN=dev-ops-only + +curl -s localhost:8081/healthz +curl -s localhost:8081/openapi.json | jq '.openapi, (.paths | keys | length)' + +curl -s -X POST localhost:8081/tools/lookup_work_order \ + -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"workOrderId":"WO-1001"}' | jq + +# tool-hiding is convenience; the boundary is server-side — a forced call to a +# tool you lack scope for is still 403: +curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:8081/tools/search_inbox \ + -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"query":"invoice"}' # → 403 (needs gmail:self) +``` + +### MCP Inspector + +Point the [MCP Inspector](https://github.com/modelcontextprotocol/inspector) at +`http://localhost:8081/mcp` (transport: **Streamable HTTP**) and set an +`Authorization: Bearer dev-assistant` header. `tools/list` reflects only the +tools your token's scopes permit. + +## Environment + +| Var | Mode | Purpose | +| ---------------------------- | ---- | --------------------------------------------- | +| `SH_MCP_ENV` | both | `local` (dev clients) or `aws` (real/Cognito) | +| `PORT` | both | listen port (default 8081) | +| `AWS_REGION` | aws | Cognito region | +| `COGNITO_USER_POOL_ID` | aws | issuer / JWKS source | +| `COGNITO_ALLOWED_CLIENT_IDS` | aws | comma-list; the audience boundary | +| `GOOGLE_MAPS_API_KEY` | aws | maps client | +| `REMINDER_TARGET_ARN` etc. | aws | scheduler wiring | + +`aws` mode is not runtime-exercised in Phase 1 (real external clients are +deferred stubs; Cognito infra is out of scope — see `docs/build-plan-phase-1.md`). + +## CDK + +`cdk/app.ts` is a **synth-only** placeholder (`npm run synth -w @sh-mcp/server-ops`) +so the CI `cdk synth` gate has a valid app. It provisions **no** real IAM, +Cognito, API Gateway, or WAF — those land in a later phase behind the mandatory +IAM cross-review. diff --git a/servers/sh-mcp-ops/cdk.json b/servers/sh-mcp-ops/cdk.json new file mode 100644 index 0000000..9e92cc2 --- /dev/null +++ b/servers/sh-mcp-ops/cdk.json @@ -0,0 +1,7 @@ +{ + "app": "tsx cdk/app.ts", + "output": "cdk.out", + "context": { + "@aws-cdk/core:newStyleStackSynthesis": true + } +} diff --git a/servers/sh-mcp-ops/cdk/app.ts b/servers/sh-mcp-ops/cdk/app.ts new file mode 100644 index 0000000..3f18351 --- /dev/null +++ b/servers/sh-mcp-ops/cdk/app.ts @@ -0,0 +1,31 @@ +/** + * sh-mcp-ops — synth-only CDK app (build-plan §6). + * + * Exists ONLY so the CI `cdk synth` gate has a valid app to synthesize, keeping + * the IaC/ARM64 wiring honest WITHOUT deploying. It defines NO real IAM roles, + * Cognito resources, API Gateway authorizers, or WAF — those carry the mandatory + * human IAM cross-review that cannot run here. The real stack is a later phase. + * + * TODO(phase-2): real stack — gated on Cognito + IAM cross-review (design.md §8). + */ + +import { App, Stack, CfnOutput, type StackProps } from 'aws-cdk-lib'; +import type { Construct } from 'constructs'; + +class ShMcpOpsStack extends Stack { + constructor(scope: Construct, id: string, props?: StackProps) { + super(scope, id, props); + + // Inert marker output only — no real resources are provisioned here. + new CfnOutput(this, 'PlatformTier', { + value: 'ops', + description: 'sh-mcp-ops trust tier (synth-only placeholder; design.md §3).', + }); + } +} + +const app = new App(); +new ShMcpOpsStack(app, 'sh-mcp-ops', { + description: 'Sea Haven MCP ops-tier server (synth-only stub — no real infra yet).', +}); +app.synth(); diff --git a/servers/sh-mcp-ops/package.json b/servers/sh-mcp-ops/package.json new file mode 100644 index 0000000..8a1f48b --- /dev/null +++ b/servers/sh-mcp-ops/package.json @@ -0,0 +1,41 @@ +{ + "name": "@sh-mcp/server-ops", + "version": "0.1.0", + "description": "Sea Haven MCP ops-tier server — MCP (Streamable HTTP) + OpenAPI 3.1 over the ops tool registry", + "license": "UNLICENSED", + "private": true, + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "dev": "tsx watch src/index.ts", + "start": "node dist/index.js", + "build": "tsc --project tsconfig.json", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "synth": "cdk synth >/dev/null" + }, + "engines": { + "node": ">=24" + }, + "dependencies": { + "@sh-mcp/calendar": "*", + "@sh-mcp/gmail": "*", + "@sh-mcp/google-maps": "*", + "@sh-mcp/internal-data": "*", + "@sh-mcp/knowledge-base": "*", + "@sh-mcp/reminders": "*", + "@sh-mcp/shared": "*", + "@sh-mcp/tasks": "*", + "express": "5.2.1" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "aws-cdk-lib": "2.260.0", + "constructs": "10.6.0", + "tsx": "4.22.4", + "typescript": "^5.5.0", + "vitest": "^2.0.0" + } +} diff --git a/servers/sh-mcp-ops/src/app.ts b/servers/sh-mcp-ops/src/app.ts new file mode 100644 index 0000000..a1a05f0 --- /dev/null +++ b/servers/sh-mcp-ops/src/app.ts @@ -0,0 +1,58 @@ +/** + * sh-mcp-ops application assembly. + * + * Wires the registry, auth provider, audit logger, and rate limiter into the + * shared Express host (build-plan §2.2). Exported separately from `index.ts` so + * tests can build the app without binding a port. + */ + +import { + ConsoleAuditLogger, + InMemoryRateLimiter, + createApp, + type DispatchDeps, +} from '@sh-mcp/shared'; +import type { Express } from 'express'; + +import { buildOpsRegistry } from './registry.js'; +import { buildOpsAuthProvider } from './auth.js'; +import { loadOpsConfig, type OpsConfig } from './config.js'; + +export interface BuildAppResult { + app: Express; + config: OpsConfig; +} + +/** Build the ops server app. Pass a config to override env loading (tests). */ +export async function buildOpsApp(configOverride?: OpsConfig): Promise { + const config = configOverride ?? loadOpsConfig(); + const registry = await buildOpsRegistry(config); + const authProvider = buildOpsAuthProvider(config); + + const deps: DispatchDeps = { + auditLogger: new ConsoleAuditLogger(), + // Generous local limits; tightened per-tier in production (design.md §7.3). + rateLimiter: new InMemoryRateLimiter({ + sessionCap: 200, + perToolLimit: 60, + windowMs: 60_000, + }), + }; + + const app = createApp({ + registry, + authProvider, + deps, + mcpInfo: { name: 'sh-mcp-ops', version: '0.1.0' }, + openApi: { + info: { + title: 'Sea Haven MCP — Ops', + version: '0.1.0', + description: 'Operations-tier tools (read-mostly). design.md §3.', + }, + servers: [{ url: `http://localhost:${config.port}`, description: 'local' }], + }, + }); + + return { app, config }; +} diff --git a/servers/sh-mcp-ops/src/auth.ts b/servers/sh-mcp-ops/src/auth.ts new file mode 100644 index 0000000..1dcc3bd --- /dev/null +++ b/servers/sh-mcp-ops/src/auth.ts @@ -0,0 +1,30 @@ +/** + * sh-mcp-ops AuthProvider selection. + * + * `aws` → CognitoAuthProvider (real JWT verification, design.md §2.5). + * `local` → LocalAuthProvider (dev bearer tokens; refuses to build unless + * SH_MCP_ENV=local, build-plan §3). + */ + +import { + CognitoAuthProvider, + LocalAuthProvider, + defaultLocalPrincipals, + type AuthProvider, +} from '@sh-mcp/shared'; + +import type { OpsConfig } from './config.js'; + +export function buildOpsAuthProvider(config: OpsConfig): AuthProvider { + if (config.env === 'aws') { + if (!config.cognito) { + throw new Error('Cognito config missing in aws mode.'); + } + return new CognitoAuthProvider(config.cognito); + } + return new LocalAuthProvider({ + audience: config.audience, + principals: defaultLocalPrincipals(), + env: 'local', + }); +} diff --git a/servers/sh-mcp-ops/src/config.ts b/servers/sh-mcp-ops/src/config.ts new file mode 100644 index 0000000..3ce857b --- /dev/null +++ b/servers/sh-mcp-ops/src/config.ts @@ -0,0 +1,70 @@ +/** + * sh-mcp-ops configuration — read entirely from the environment. + * + * Nothing is hardcoded (build-plan §7, design.md §2): client ids, issuer, JWKS + * URL, table names, scope prefix all arrive via env. `SH_MCP_ENV` selects local + * (dev clients + LocalAuthProvider) vs aws (real stub clients + Cognito). + */ + +import { cognitoIssuer, cognitoJwks, type CognitoAuthConfig } from '@sh-mcp/shared'; + +export type Env = 'local' | 'aws'; + +export interface OpsConfig { + env: Env; + port: number; + audience: string; + /** Cognito config — only required/used in `aws` mode. */ + cognito?: CognitoAuthConfig; + // Real-client settings (aws mode only; unused locally). + googleMapsApiKey?: string; + reminderTargetArn?: string; + schedulerRoleArn?: string; +} + +const AUDIENCE = 'sh-mcp-ops'; +const SCOPE_PREFIX = 'sh-mcp-ops'; + +function readEnv(name: string): string | undefined { + const v = process.env[name]; + return v !== undefined && v.length > 0 ? v : undefined; +} + +/** Parse and validate the process environment into an {@link OpsConfig}. */ +export function loadOpsConfig(): OpsConfig { + const rawEnv = readEnv('SH_MCP_ENV') ?? 'local'; + if (rawEnv !== 'local' && rawEnv !== 'aws') { + throw new Error(`SH_MCP_ENV must be "local" or "aws" (got "${rawEnv}").`); + } + const env = rawEnv; + const port = Number(readEnv('PORT') ?? '8081'); + + const base: OpsConfig = { env, port, audience: AUDIENCE }; + + if (env === 'aws') { + const region = readEnv('AWS_REGION') ?? 'us-east-1'; + const userPoolId = required('COGNITO_USER_POOL_ID'); + const allowedClientIds = required('COGNITO_ALLOWED_CLIENT_IDS') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + base.cognito = { + issuer: cognitoIssuer(region, userPoolId), + audience: AUDIENCE, + allowedClientIds, + scopePrefix: SCOPE_PREFIX, + jwks: cognitoJwks(region, userPoolId), + }; + base.googleMapsApiKey = readEnv('GOOGLE_MAPS_API_KEY'); + base.reminderTargetArn = readEnv('REMINDER_TARGET_ARN'); + base.schedulerRoleArn = readEnv('SCHEDULER_ROLE_ARN'); + } + + return base; +} + +function required(name: string): string { + const v = readEnv(name); + if (!v) throw new Error(`Missing required env var ${name} for SH_MCP_ENV=aws.`); + return v; +} diff --git a/servers/sh-mcp-ops/src/index.ts b/servers/sh-mcp-ops/src/index.ts new file mode 100644 index 0000000..cd076d4 --- /dev/null +++ b/servers/sh-mcp-ops/src/index.ts @@ -0,0 +1,45 @@ +/** + * sh-mcp-ops entrypoint. + * + * Builds the app and starts listening. This is the ONLY place a listener is + * created — importing any module above is side-effect free (build-plan §2.1). + * + * A `handler` placeholder is exported for a future Lambda adapter, but this PR + * does NOT depend on the AWS Lambda runtime (build-plan §2.2). + */ + +import { buildOpsApp } from './app.js'; + +export { buildOpsApp } from './app.js'; + +async function main(): Promise { + const { app, config } = await buildOpsApp(); + app.listen(config.port, () => { + console.log( + JSON.stringify({ + msg: 'sh-mcp-ops listening', + env: config.env, + port: config.port, + endpoints: ['/mcp', '/openapi.json', 'POST /tools/:name', '/healthz'], + }), + ); + }); +} + +/** + * Placeholder Lambda handler shape for a future phase. Intentionally throws — + * the HTTP server (`main`) is the supported runtime in Phase 1. + */ +export function handler(): never { + throw new Error('Lambda handler is not implemented in Phase 1; run the HTTP server.'); +} + +// Start only when executed directly (not when imported by tests). +const invokedDirectly = + process.argv[1] !== undefined && import.meta.url === `file://${process.argv[1]}`; +if (invokedDirectly) { + main().catch((err: unknown) => { + console.error('Failed to start sh-mcp-ops:', err); + process.exit(1); + }); +} diff --git a/servers/sh-mcp-ops/src/registry.ts b/servers/sh-mcp-ops/src/registry.ts new file mode 100644 index 0000000..c9466a6 --- /dev/null +++ b/servers/sh-mcp-ops/src/registry.ts @@ -0,0 +1,135 @@ +/** + * sh-mcp-ops tool registry — composition root. + * + * Registers exactly the ops-tier tools (design.md §3): internal-data, + * knowledge-base, google-maps, gmail, calendar, tasks, reminders. Each package + * factory is wired with the selected client — an in-memory dev client in + * `SH_MCP_ENV=local`, or the real (stub) client in `aws` mode. + * + * IMPORTANT: we deliberately call each package's *factory* with an explicit + * client rather than importing its pre-wired `tools` array, because some of + * those arrays construct AWS clients at import time. Building clients here keeps + * import-time side-effect-free (build-plan §7 "No I/O at import time"). + * + * The package factory signatures are intentionally inconsistent (build-plan §1); + * each is wired to its own shape below. + */ + +import { ToolRegistry, type ToolDef } from '@sh-mcp/shared'; + +// internal-data +import { + makeTools as makeInternalDataTools, + RealDynamoClient, + type InternalDataClient, + InMemoryInternalDataClient, +} from '@sh-mcp/internal-data'; +// knowledge-base +import { + createKnowledgeBaseTools, + createBedrockClientFromEnv, + type KnowledgeBaseClient, + InMemoryKnowledgeBaseClient, +} from '@sh-mcp/knowledge-base'; +// google-maps +import { + makeTools as makeMapsTools, + GooglePlacesClient, + type GoogleMapsClient, + InMemoryGoogleMapsClient, +} from '@sh-mcp/google-maps'; +// gmail +import { + makeGmailTools, + GmailApiClient, + type GmailClient, + type GoogleTokenProvider as GmailTokenProvider, + InMemoryGmailClient, +} from '@sh-mcp/gmail'; +// calendar +import { + buildCalendarTools, + GoogleCalendarClient, + type CalendarClient, + type GoogleTokenProvider as CalendarTokenProvider, + InMemoryCalendarClient, +} from '@sh-mcp/calendar'; +// tasks +import { + buildTaskTools, + DynamoDBTasksClient, + type TasksClient, + InMemoryTasksClient, +} from '@sh-mcp/tasks'; +// reminders +import { + buildReminderTools, + AwsSchedulerClient, + type SchedulerClient, + InMemorySchedulerClient, +} from '@sh-mcp/reminders'; + +import type { OpsConfig } from './config.js'; + +/** + * A Google per-user token provider stub for `aws` mode. The REAL provider + * (per-user OAuth refresh tokens, design.md §2.4) is a later phase; in this PR + * `aws` mode is not runtime-exercised, so this throws if actually used. + */ +class StubGoogleTokenProvider implements GmailTokenProvider, CalendarTokenProvider { + async getAccessToken(): Promise { + throw new Error( + 'Real per-user Google token provider is not implemented in Phase 1 ' + + '(design.md §2.4 — deferred). Run with SH_MCP_ENV=local for a working server.', + ); + } +} + +/** Build the ops registry for the given environment. */ +export async function buildOpsRegistry(config: OpsConfig): Promise { + const local = config.env === 'local'; + + // --- select clients (dev vs real) --- + const internalDataClient: InternalDataClient = local + ? new InMemoryInternalDataClient() + : await RealDynamoClient.create(); + + const kbClient: KnowledgeBaseClient = local + ? new InMemoryKnowledgeBaseClient() + : createBedrockClientFromEnv(); + + const mapsClient: GoogleMapsClient = local + ? new InMemoryGoogleMapsClient() + : new GooglePlacesClient(config.googleMapsApiKey ?? ''); + + const tokenProvider = new StubGoogleTokenProvider(); + const gmailClient: GmailClient = local + ? new InMemoryGmailClient() + : new GmailApiClient(tokenProvider); + const calendarClient: CalendarClient = local + ? new InMemoryCalendarClient() + : new GoogleCalendarClient(tokenProvider); + + const tasksClient: TasksClient = local ? new InMemoryTasksClient() : new DynamoDBTasksClient(); + + const schedulerClient: SchedulerClient = local + ? new InMemorySchedulerClient() + : new AwsSchedulerClient({ + targetArn: config.reminderTargetArn ?? '', + roleArn: config.schedulerRoleArn ?? '', + }); + + // --- register all ops-tier tools --- + const registry = new ToolRegistry(); + const all: ToolDef[] = [ + ...makeInternalDataTools(internalDataClient), + ...createKnowledgeBaseTools(kbClient), + ...makeMapsTools(mapsClient), + ...makeGmailTools(gmailClient), + ...buildCalendarTools(calendarClient), + ...buildTaskTools(tasksClient), + ...buildReminderTools({ client: schedulerClient }), + ].map((t) => t as ToolDef); + for (const tool of all) registry.register(tool); + return registry; +} diff --git a/servers/sh-mcp-ops/tsconfig.json b/servers/sh-mcp-ops/tsconfig.json new file mode 100644 index 0000000..b984810 --- /dev/null +++ b/servers/sh-mcp-ops/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "declarationDir": "dist" + }, + "include": ["src"], + "references": [ + { "path": "../../packages/shared" }, + { "path": "../../packages/calendar" }, + { "path": "../../packages/gmail" }, + { "path": "../../packages/google-maps" }, + { "path": "../../packages/internal-data" }, + { "path": "../../packages/knowledge-base" }, + { "path": "../../packages/reminders" }, + { "path": "../../packages/tasks" } + ] +}