Add runnable sh-mcp-ops and sh-mcp-finance servers

Two thin composition-root servers over the shared transport (design.md §3):
- ops: internal-data, knowledge-base, google-maps, gmail, calendar, tasks,
  reminders. finance: qbo, payments (audited + redacted on egress).
- config from env only (no hardcoded ids/issuer/tables); SH_MCP_ENV selects
  LocalAuthProvider + dev clients (local) vs CognitoAuthProvider + real stubs
  (aws). Finance applies the 15-min finance-token TTL ceiling (design.md §2.5).
- index.ts is the only place .listen() is called; a Lambda handler placeholder
  is exported but not depended on.
- synth-only CDK stubs (no real IAM/Cognito/WAF) so 'cdk synth' has a valid app
  (build-plan §6); READMEs document local run, dev tokens, curl, MCP Inspector.
This commit is contained in:
Adam Moussa 2026-06-24 20:42:30 -04:00
parent 2e16c306b0
commit 9bf85aef29
20 changed files with 940 additions and 0 deletions

View file

@ -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": "<sha256>",
"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.

View file

@ -0,0 +1,7 @@
{
"app": "tsx cdk/app.ts",
"output": "cdk.out",
"context": {
"@aws-cdk/core:newStyleStackSynthesis": true
}
}

View file

@ -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();

View file

@ -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"
}
}

View file

@ -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 };
}

View file

@ -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',
});
}

View file

@ -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;
}

View file

@ -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);
}
}

View file

@ -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<unknown, unknown>[] = [
makeSearchVendorsTool(qboClient),
...makePaymentsTools(paymentsClient),
].map((t) => t as ToolDef<unknown, unknown>);
for (const tool of all) registry.register(tool);
return registry;
}

View file

@ -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" }
]
}

View file

@ -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.

View file

@ -0,0 +1,7 @@
{
"app": "tsx cdk/app.ts",
"output": "cdk.out",
"context": {
"@aws-cdk/core:newStyleStackSynthesis": true
}
}

View file

@ -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();

View file

@ -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"
}
}

View file

@ -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<BuildAppResult> {
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 };
}

View file

@ -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',
});
}

View file

@ -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;
}

View file

@ -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<void> {
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);
});
}

View file

@ -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<string> {
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<ToolRegistry> {
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<unknown, unknown>[] = [
...makeInternalDataTools(internalDataClient),
...createKnowledgeBaseTools(kbClient),
...makeMapsTools(mapsClient),
...makeGmailTools(gmailClient),
...buildCalendarTools(calendarClient),
...buildTaskTools(tasksClient),
...buildReminderTools({ client: schedulerClient }),
].map((t) => t as ToolDef<unknown, unknown>);
for (const tool of all) registry.register(tool);
return registry;
}

View file

@ -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" }
]
}