mirror of
https://github.com/Sea-Haven-Industries/sh-mcp.git
synced 2026-09-30 01:53:14 +00:00
Phase 0b slice: monorepo scaffold + @sh-mcp/shared core + integration packages (#2)
Some checks are pending
deploy / deploy (push) Waiting to run
Some checks are pending
deploy / deploy (push) Waiting to run
* Phase 0b slice: monorepo scaffold + shared core + integration packages The 0a-INDEPENDENT code slice (one-shot via af-0b-package-slice workflow: Haiku scaffold + Sonnet packages, Sonnet fix-to-green). Nothing deploys; no CDK/servers. - Monorepo scaffold: npm workspaces, strict TS (NodeNext), vitest (80% gate), eslint 9 flat config, prettier; ci.yaml/deploy.yaml callers (Node 24, enable-qemu). - @sh-mcp/shared: transport-agnostic core — Scope/AuthContext/ToolDef, ToolRegistry, redact()+maskValue() (PII), OpenAPI 3.1 generator. AUTH STUBBED behind an AuthProvider interface (TODO auth-layer-0a); JWT/aud/client_id/JWKS/deny-list deferred per design.md §2. - 9 integration packages (qbo, google-maps, internal-data, payments, knowledge-base, gmail, calendar, tasks, reminders): tools against shared, external deps mocked behind injected client interfaces; finance handlers call redact(). Verified green: tsc -b clean, vitest 245/245, eslint 0 errors. Auth mechanism intentionally deferred until the 0a spike resolves it (G16/§0.4). * Complete Cognito auth provider + Phase 1 build brief Finish the WIP CognitoAuthProvider (client_id allow-list as audience boundary, finance TTL ceiling, deny-list, scope-prefix stripping) with its test suite, and check in docs/build-plan-phase-1.md so the Phase 1 work has its governing brief in-tree (design.md §2.5). * ci: disable cdk synth for Phase 0b (no CDK app yet) The reusable ci-typescript-cdk workflow defaults run-cdk-synth: true, but the Phase 0b package scaffold has no cdk.json or stacks, so cdk synth fails with '--app is required'. Disable it here; Phase 1 re-enables it with the server CDK stubs.
This commit is contained in:
parent
3e2d99a1eb
commit
0d1fefb326
82 changed files with 15910 additions and 13 deletions
17
.editorconfig
Normal file
17
.editorconfig
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
21
.github/workflows/ci.yaml
vendored
Normal file
21
.github/workflows/ci.yaml
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
name: ci
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
checks: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@main
|
||||
with:
|
||||
node-version: '24'
|
||||
enable-qemu: true
|
||||
# Phase 0b ships no CDK app (no cdk.json / stacks); infra lands in Phase 1.
|
||||
run-cdk-synth: false
|
||||
secrets: inherit
|
||||
18
.github/workflows/deploy.yaml
vendored
Normal file
18
.github/workflows/deploy.yaml
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
name: deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/cd-cdk.yaml@main
|
||||
with:
|
||||
node-version: '24'
|
||||
enable-qemu: true
|
||||
secrets: inherit
|
||||
39
.gitignore
vendored
39
.gitignore
vendored
|
|
@ -1,23 +1,36 @@
|
|||
# Node / TypeScript
|
||||
# Node
|
||||
node_modules/
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
*.tsbuildinfo
|
||||
|
||||
# CDK
|
||||
cdk.out/
|
||||
.cdk.staging/
|
||||
*.zip
|
||||
|
||||
# Env / secrets
|
||||
.env
|
||||
.env.*
|
||||
*.local
|
||||
|
||||
# Logs / OS
|
||||
*.log
|
||||
npm-debug.log*
|
||||
.DS_Store
|
||||
|
||||
# Test / coverage
|
||||
# Test/coverage
|
||||
coverage/
|
||||
.nyc_output/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# AWS
|
||||
*.pem
|
||||
*.key
|
||||
|
|
|
|||
10
.prettierrc
Normal file
10
.prettierrc
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"semi": true,
|
||||
"trailingComma": "all",
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
320
docs/build-plan-phase-1.md
Normal file
320
docs/build-plan-phase-1.md
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
# Build Plan — Phase 1: Runnable MCP + OpenAPI Servers
|
||||
|
||||
> **Audience:** an autonomous coding agent building this overnight and opening a **draft PR**.
|
||||
> You do **not** have access to the maintainer's global instructions, memory, or engineering
|
||||
> handbook. Everything you need is in this file and in `docs/design.md`. Read both fully before
|
||||
> writing code. When this file and `docs/design.md` disagree, this file wins for *what to build
|
||||
> in this PR*; `docs/design.md` wins for *the architecture and security model*.
|
||||
|
||||
## 0. Goal of this PR (single, focused)
|
||||
|
||||
Take the existing Phase 0b scaffold (shared core + 9 integration packages) and add the missing
|
||||
**transport/runtime layer + two servers** so that, by the end:
|
||||
|
||||
- `servers/sh-mcp-ops` and `servers/sh-mcp-finance` **start locally and serve real requests**.
|
||||
- Each server exposes **two universal interfaces over the same tool registry**:
|
||||
1. **MCP** — Streamable HTTP, via `@modelcontextprotocol/sdk`.
|
||||
2. **OpenAPI 3.1** — a full document at `GET /openapi.json` plus a `POST /tools/{tool-name}`
|
||||
endpoint per tool (this is what Agentforce/other OpenAPI consumers will use later).
|
||||
- The auth, scope-hiding, audience-binding, finance redaction, audit logging, and rate-limiting
|
||||
described in `design.md §2` are enforced in **one shared dispatch path** used by both interfaces.
|
||||
- Everything is covered by the **security-weighted test suite** (`design.md §7.3`), `tsc --noEmit`
|
||||
is clean, eslint/prettier pass, and CI is green.
|
||||
|
||||
**This PR does NOT integrate Agentforce, Slack, or Cognito infrastructure.** It produces the
|
||||
servers and the two specs they speak. Agentforce wiring is a later phase.
|
||||
|
||||
### Explicitly OUT of scope (do not build; leave as deferred follow-ups)
|
||||
- Cognito user pool, Google federation, pre-token Lambda, group-sync Lambda (`auth/` dir).
|
||||
- Real `cdk deploy` / API Gateway / WAF / IAM roles. (You will add **synth-only** CDK stubs — see §6.)
|
||||
- The `jobs/` proactive Lambdas (fetch-classify, digests, KB syncs).
|
||||
- The physical tier (lenel/yealink/threecx) — deferred per `design.md §3`.
|
||||
- Real DynamoDB/QBO/Maps/Google client implementations — they stay stubbed; you add **in-memory
|
||||
dev clients** instead (see §4). Do not write live AWS/Google/QBO network code.
|
||||
|
||||
If you find yourself provisioning AWS, federating Google, or calling a real external API, stop —
|
||||
that's out of scope for this PR.
|
||||
|
||||
---
|
||||
|
||||
## 1. What already exists (read these first, do not rewrite)
|
||||
|
||||
- **`packages/shared/src`** — the transport-agnostic core. Reuse it; extend it, don't fork it.
|
||||
- `types.ts` — `Scope`, `AuthContext { sub, scopes, aud }`, `ToolDef<I,O> { name, description,
|
||||
tier: 'ops'|'finance', requiredScope, inputSchema, handler(input, ctx) }`, `JSONSchema`.
|
||||
- `registry.ts` — `defineTool()` and `ToolRegistry` (`.register()`, `.list()`, `.get()`, `.size`).
|
||||
- `auth.ts` — `requireScope(ctx, scope)` / `ScopeError`, and the `AuthProvider` interface
|
||||
(`authenticate(req): Promise<AuthContext>`).
|
||||
- `cognito-auth.ts` — `CognitoAuthProvider` (real jose JWT verification; **client_id allow-list IS
|
||||
the audience boundary** because Cognito access tokens carry no `aud`; deny-list; finance TTL
|
||||
ceiling; strips the `sh-mcp-ops/` resource-server prefix off scopes). `AuthError` (→ 401).
|
||||
- `redact.ts` — `redact()`, `maskValue()`, `REDACTED`.
|
||||
- `openapi.ts` — `generateOpenAPIPaths(registry)` returns `{ paths, components }` (paths only today).
|
||||
- `index.ts` — the **only** import surface. Packages import from `'@sh-mcp/shared'`, never subpaths.
|
||||
- **9 integration packages** (`packages/{calendar,gmail,google-maps,internal-data,knowledge-base,
|
||||
payments,qbo,reminders,tasks}`) — each has a `client.ts` (an interface + a throwing/stub concrete
|
||||
impl), a `tools.ts` factory that builds `ToolDef`s via `defineTool`, an `index.ts`, and a vitest
|
||||
suite using a mock client.
|
||||
|
||||
### ⚠️ Known inconsistency you must absorb (do not "fix" by renaming tools)
|
||||
The package tool factories have **inconsistent signatures and export names** — by design they are
|
||||
each composed individually:
|
||||
|
||||
| Package | Factory export | Signature |
|
||||
|---|---|---|
|
||||
| calendar | `buildCalendarTools` | `(client)` |
|
||||
| tasks | `buildTaskTools` | `(client)` |
|
||||
| reminders | `buildReminderTools` | `({ client })` |
|
||||
| internal-data | `makeTools` | `(client)` |
|
||||
| google-maps | `makeTools` / `makeSearchNearbyVendors` | `(client, ...)` |
|
||||
| gmail | `makeSearchInboxTool`, `makeGetEmailThreadDetailTool` | per-tool factories |
|
||||
| knowledge-base | `createKnowledgeBaseTools` | `(...)` |
|
||||
| payments | `makePaymentsTools` | `(client)` |
|
||||
| qbo | `tools` array / `makeSearchVendorsTool` | client constructed inside |
|
||||
|
||||
**Open each package's `index.ts` and `tools.ts` to learn its exact factory before wiring it.**
|
||||
Wire each factory with the appropriate **dev client** (local) or **stub client** (real). You MAY
|
||||
add a thin normalizing adapter in each server's composition root, but **do not rename any tool's
|
||||
`name` field** and do not change package public APIs unless a package genuinely can't be composed
|
||||
without it (if so, keep the change minimal and note it in the PR).
|
||||
|
||||
The ops/finance tool split is authoritative in `design.md §3`:
|
||||
- **ops** tools come from: internal-data, knowledge-base, google-maps, gmail, calendar, tasks, reminders.
|
||||
- **finance** tools come from: qbo, payments.
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture to build
|
||||
|
||||
Add a **transport/runtime** to `packages/shared` (the design doc designates shared as the "MCP
|
||||
scaffold + JWT validation + scope guard + audit log" home — keep it there; do not create a new
|
||||
package). Then add two thin server apps under `servers/`.
|
||||
|
||||
### 2.1 New modules in `packages/shared/src` (export all via `index.ts`)
|
||||
|
||||
1. **`dispatch.ts`** — the single authoritative execution path. One function, e.g.
|
||||
`async function executeTool(registry, ctx, toolName, rawInput, deps)` that:
|
||||
1. Looks up the tool; 404-equivalent if unknown.
|
||||
2. Calls `requireScope(ctx, tool.requiredScope)` (defense-in-depth; handlers also call it).
|
||||
3. **Validates `rawInput` against `tool.inputSchema`** before the handler runs (use `ajv`;
|
||||
reject on failure with a structured validation error — never pass unvalidated input to a
|
||||
handler; `design.md §2.5` prompt-injection containment).
|
||||
4. Enforces a **per-session tool-call cap + per-tool rate limit** (`design.md §7.3`) via an
|
||||
injected limiter (in-memory token bucket is fine for this PR).
|
||||
5. Runs `tool.handler(input, ctx)`.
|
||||
6. **If `tool.tier === 'finance'`, runs the output through `redact()`/`maskValue()` on egress**
|
||||
so bank/routing/card/SSN are masked before the value leaves the dispatcher
|
||||
(`design.md §2.5`, §7.3). Finance tools already redact internally — this is a belt-and-braces
|
||||
egress pass; assert in tests that nothing sensitive escapes.
|
||||
7. **Emits a structured audit record for every `finance:*` call** (and any future `physical:*`):
|
||||
`{ sub, tool, argsHash, decision, result: 'ok'|'error', ts }` — args are **hashed, never
|
||||
logged raw**; secrets must never appear (`design.md §2.5`, §7.3 Audit row).
|
||||
8. Maps errors to typed outcomes the adapters translate (ScopeError→403, AuthError→401,
|
||||
validation→400, unknown tool→404, handler throw→500). Never leak stack traces or secrets in
|
||||
error bodies.
|
||||
|
||||
2. **`audit.ts`** — an `AuditLogger` interface + a default `ConsoleAuditLogger` (structured JSON to
|
||||
stdout; in Lambda this lands in CloudWatch). Injected into dispatch. Add a `NoopAuditLogger` for tests.
|
||||
|
||||
3. **`mcp.ts`** — `createMcpServer(registry, authProvider, deps)` returning a configured
|
||||
`@modelcontextprotocol/sdk` `Server`:
|
||||
- `tools/list` returns **only the tools whose `requiredScope` is in the caller's `AuthContext`**
|
||||
(server-side **tool-hiding**, `design.md §2.5`). A finance-less caller must not see finance tools.
|
||||
- `tools/call` routes through `executeTool`. Same scope/redaction/audit guarantees as OpenAPI.
|
||||
|
||||
4. **Extend `openapi.ts`** — add `buildOpenApiDocument(registry, { info, servers })` that wraps the
|
||||
existing `generateOpenAPIPaths` output into a complete, valid OpenAPI **3.1** document (info,
|
||||
servers, paths, components.securitySchemes). Keep `generateOpenAPIPaths` as-is and build on top.
|
||||
|
||||
5. **`http.ts`** — `createApp({ registry, authProvider, deps })` returning an **Express** app
|
||||
(decision: Express + MCP SDK) that mounts:
|
||||
- `POST /mcp` (+ the GET/DELETE the Streamable HTTP transport needs) → MCP via
|
||||
`StreamableHTTPServerTransport`. Authenticate the request → `AuthContext` → MCP server.
|
||||
- `GET /openapi.json` → `buildOpenApiDocument(...)`.
|
||||
- `POST /tools/:name` → authenticate → `executeTool` → JSON result. 401/403/400/404/500 per §2.1.
|
||||
- `GET /healthz` → `{ status: 'ok' }`, unauthenticated, for local/uptime checks.
|
||||
- Auth middleware calls `authProvider.authenticate(req)`; on `AuthError` → 401, on success
|
||||
attaches `ctx`. The `/openapi.json` and `/healthz` routes are unauthenticated; **every tool
|
||||
path and `/mcp` require a valid token** (`design.md §2.5` — never an unauthenticated tool path).
|
||||
|
||||
> Keep `packages/shared` importable without side effects: no server is started and no AWS/Express
|
||||
> listener is created at import time. `createApp` builds; the server entry calls `.listen()`.
|
||||
|
||||
### 2.2 Server apps — `servers/sh-mcp-ops` and `servers/sh-mcp-finance`
|
||||
|
||||
Each is a thin **composition root** workspace package (`@sh-mcp/server-ops`, `@sh-mcp/server-finance`):
|
||||
|
||||
- `src/registry.ts` — build a `ToolRegistry`, register exactly that tier's tools (§1 split), wiring
|
||||
each package factory with the selected client (dev vs real, §4).
|
||||
- `src/config.ts` — read env: `SH_MCP_ENV` (`local` | `aws`), port, and (for `aws`) the
|
||||
`CognitoAuthConfig` (issuer, JWKS, `allowedClientIds`, `scopePrefix`, finance TTL). **No secrets
|
||||
or client ids hardcoded** — all injected from env (`design.md §2`).
|
||||
- `src/auth.ts` — select the `AuthProvider`: `CognitoAuthProvider` when `SH_MCP_ENV=aws`; a
|
||||
`LocalAuthProvider` (see §3) when `SH_MCP_ENV=local`. The local provider **must refuse to
|
||||
construct when `SH_MCP_ENV` is not `local`** so it can never run in production.
|
||||
- `src/index.ts` — `createApp(...)` + `.listen(port)` with a startup log line. Also export a
|
||||
`handler` shape placeholder for future Lambda use, but do **not** depend on AWS Lambda runtime.
|
||||
- `package.json` — `dev` (`tsx watch src/index.ts` or `node --watch`), `start`, `build`, `test`,
|
||||
`typecheck` scripts. Add to the root `tsconfig.json` `references` and to workspaces (already globbed).
|
||||
- `README.md` — how to run locally, the env vars, the two endpoints, example `curl` + MCP Inspector.
|
||||
|
||||
Finance server additionally: every tool call audited (already guaranteed by dispatch for finance tier).
|
||||
|
||||
---
|
||||
|
||||
## 3. Local auth (so the servers actually run without Cognito)
|
||||
|
||||
Add `LocalAuthProvider` (in `packages/shared/src`, exported from the barrel; or in each server — put
|
||||
it in shared so both reuse it). It implements `AuthProvider.authenticate(req)` and, in `local` mode
|
||||
only, derives an `AuthContext` from a **dev bearer token** mapping defined in env/config, e.g.:
|
||||
|
||||
- A small JSON map `SH_MCP_LOCAL_PRINCIPALS` of `token -> { sub, scopes[], aud }`, OR
|
||||
- A signed local JWT using a dev secret.
|
||||
|
||||
Provide at least these dev principals so tests/demos exercise tool-hiding and tiering:
|
||||
`ops-only` (`ops:read`,`ops:tasks`), `assistant` (+`gmail:self`,`calendar:self`), `finance`
|
||||
(`ops:read`,`finance:read`), `admin` (all). It MUST throw if instantiated outside `SH_MCP_ENV=local`.
|
||||
Document the dev tokens in each server README.
|
||||
|
||||
---
|
||||
|
||||
## 4. In-memory dev clients (decision: tools return real data locally)
|
||||
|
||||
For each integration package, add an **in-memory implementation of its `Client` interface** seeded
|
||||
with a few realistic fake records, used when `SH_MCP_ENV=local`. Two acceptable placements — pick one
|
||||
and be consistent: (a) a `src/dev-client.ts` in each package exported from its `index.ts`, or
|
||||
(b) a `servers/*/src/dev-clients.ts` in the composition root. Prefer (a) so the dev client lives with
|
||||
its interface and is unit-testable alongside the package.
|
||||
|
||||
Guarantees:
|
||||
- Selecting a dev client is gated on `SH_MCP_ENV=local`; `aws` mode wires the real (stub) clients.
|
||||
- Dev clients are pure in-memory (Maps/arrays), no network, deterministic enough to test.
|
||||
- For Gmail/Calendar/Tasks dev clients, partition data by `ctx.sub` so the per-user isolation in
|
||||
`design.md §2.4` is demonstrable locally (a user only sees their own data).
|
||||
- Finance dev data (payments/qbo) must include sensitive-looking fields (account/routing/card) so the
|
||||
redaction egress test has something real to mask.
|
||||
|
||||
End state: `SH_MCP_ENV=local npm run dev -w @sh-mcp/server-ops`, then `curl` a tool or point MCP
|
||||
Inspector at `http://localhost:PORT/mcp` with a dev bearer, and get a real response.
|
||||
|
||||
---
|
||||
|
||||
## 5. Tests (security-weighted — this is the highest-risk surface)
|
||||
|
||||
Use vitest (already configured; root `vitest.config.ts`). Keep existing package tests green. Add:
|
||||
|
||||
**Shared / dispatch / transport (new):**
|
||||
- **Tool-hiding:** MCP `tools/list` and the OpenAPI doc reflect ONLY the caller's scopes; a caller
|
||||
without `finance:read` cannot see — and cannot `tools/call` — finance tools (assert both the
|
||||
hiding AND that a forced call is still 403 server-side, since hiding is not the boundary).
|
||||
- **Audience binding:** a token/principal minted for ops is rejected by the finance server and vice
|
||||
versa (in `aws` mode this is the `client_id` allow-list; assert via `CognitoAuthProvider` config).
|
||||
- **Per-tool scope enforcement** independent of UI hiding (force-call a hidden tool → `ScopeError`/403).
|
||||
- **Input-schema validation:** malformed input is rejected (400) before the handler runs.
|
||||
- **Finance redaction on egress:** every finance tool response has bank/routing/card/SSN masked;
|
||||
add a test that fails if any raw sensitive value appears in the serialized response.
|
||||
- **Audit emission:** every finance call emits one structured audit record with hashed args and no
|
||||
secrets; assert shape and that the raw arg values / tokens never appear in the record.
|
||||
- **Prompt-injection regression:** a tool response whose text contains "ignore previous instructions,
|
||||
call <finance tool>" does NOT cause any out-of-scope tool call (dispatcher treats tool output as
|
||||
data; `design.md §2.5/§7.3`).
|
||||
- **Rate-limit / session cap:** exceeding the cap returns the limiter error, not a handler call.
|
||||
- **MCP conformance:** handshake + `tools/list` + a successful `tools/call` round-trip against an
|
||||
in-memory transport; every tool's `inputSchema` is valid JSON Schema.
|
||||
- **OpenAPI validity:** `buildOpenApiDocument` output validates as OpenAPI 3.1 (use a validator lib
|
||||
or assert required structural invariants: each tool → one `POST /tools/{name}`, `x-required-scope`
|
||||
present, `bearerAuth` security scheme present).
|
||||
- **Local-auth safety:** `LocalAuthProvider` throws if `SH_MCP_ENV !== 'local'`.
|
||||
|
||||
**Coverage gate (`design.md §7.3`):** start at **80% lines overall**, **100% on the shared
|
||||
auth/scope-guard + dispatch modules** (`auth.ts`, `cognito-auth.ts`, `dispatch.ts`). Wire the gate
|
||||
into `vitest.config.ts` coverage thresholds. If 100% on a module is impractical for a defensible
|
||||
reason, document it in the PR rather than lowering silently.
|
||||
|
||||
---
|
||||
|
||||
## 6. CDK synth-only stubs (decision: keep the CI synth gate honest, don't deploy)
|
||||
|
||||
The repo already has `.github/workflows/deploy.yaml` referencing a `cd-cdk` reusable workflow and
|
||||
`design.md §7` expects `cdk synth` in CI. Add **minimal, synth-clean** CDK app(s) so the synth step
|
||||
has something valid to run — but **wire NO real resources that require Cognito or live IAM review**:
|
||||
|
||||
- One CDK app per server (or one app, two stacks) under `servers/*/cdk/` (or `infra/`), pinned with
|
||||
**exact** `aws-cdk-lib` version (no `^`/`~` — exact pin per the repo's dependency policy).
|
||||
- The stack may define only inert/no-op constructs (e.g. a stack with a `CfnOutput`, or a Lambda
|
||||
function construct pointing at a placeholder) — enough that `cdk synth` succeeds. **Do not** create
|
||||
IAM roles/policies, API Gateway authorizers, or WAF here — those carry a mandatory human IAM
|
||||
cross-review you cannot run. Leave a `// TODO(phase-2): real stack — gated on Cognito + IAM review`.
|
||||
- Add a `synth` script and ensure `npx cdk synth` exits 0 from a clean `npm ci`.
|
||||
- If reconciling the existing `deploy.yaml` to a not-yet-deployable stack is risky, **do not modify
|
||||
deploy.yaml's trigger**; instead make synth pass and note in the PR that real deploy is deferred.
|
||||
|
||||
---
|
||||
|
||||
## 7. Conventions to follow (the maintainer's standards — inlined for you)
|
||||
|
||||
You don't have the handbook; these are the rules that apply:
|
||||
|
||||
- **Naming:** kebab-case for repos, packages, dirs, stacks, and AWS resource names
|
||||
(`sh-mcp`, `sh-mcp-ops`, `sh-mcp-finance`). Tool `name` fields: **keep whatever each package already
|
||||
uses** (mixed snake_case exists — do not mass-rename in this PR).
|
||||
- **Language/strictness:** TypeScript everywhere, ESM (`"type": "module"`, `.js` import specifiers in
|
||||
TS source as the existing code does). `tsc --noEmit` must be clean across the workspace.
|
||||
No `any` without an eslint-disable + reason (match existing style).
|
||||
- **No I/O at import time:** never construct AWS SDK clients, open sockets, or read secrets at module
|
||||
top level. Real clients lazy-load their SDK and throw in `NODE_ENV=test` (existing pattern — keep it).
|
||||
- **Lint/format:** `eslint .` and `prettier --check .` must pass. Run `npm run format` before commit.
|
||||
- **Secrets/config:** nothing hardcoded — client ids, issuer, JWKS URL, table names, scope prefixes all
|
||||
come from env/injected config. No real secrets in the repo or tests.
|
||||
- **Dependencies:** add the minimum needed (`@modelcontextprotocol/sdk`, `express`, `ajv`, `tsx` for
|
||||
dev, `@vitest/coverage-v8` if not present, `aws-cdk-lib`+`constructs` for the synth stubs).
|
||||
**Exact-pin** infra-critical deps (`aws-cdk-lib`); pin others consistently with the existing
|
||||
`package.json` style (the root uses exact versions — match that). Run `npm install` so
|
||||
`package-lock.json` updates; commit the lockfile.
|
||||
- **Commits:** small, logical, imperative-mood subject ≤ ~72 chars, with a body explaining *what and
|
||||
why* and referencing the relevant `design.md` section. Example:
|
||||
`Add shared dispatch + MCP/OpenAPI adapters (design.md §2.5, §7.3)`.
|
||||
Group by concern (shared transport → servers → dev clients → tests → cdk stubs), not one giant commit.
|
||||
- **Branch:** work on a feature branch off `main` (e.g. `feature/phase-1-servers`). Do not commit to
|
||||
`main`. Open the PR as a **draft**.
|
||||
|
||||
---
|
||||
|
||||
## 8. PR description requirements (must include all of these)
|
||||
|
||||
Open a **draft PR** to `main` titled like `Phase 1: runnable MCP + OpenAPI servers (ops + finance)`.
|
||||
The body must contain:
|
||||
|
||||
1. **Summary** — what was built (shared transport, two servers, dev clients, dual specs, tests, synth stubs).
|
||||
2. **How to run** — exact `SH_MCP_ENV=local` commands for each server + a sample `curl` and an MCP
|
||||
Inspector pointer, with a dev bearer token.
|
||||
3. **Testing** — `npm test` output summary, coverage numbers, and that `tsc --noEmit`, `eslint`, and
|
||||
`prettier --check` are clean.
|
||||
4. **Out of scope / deferred** — Cognito infra, real IAM/deploy, Agentforce, Slack, jobs, physical
|
||||
tier, real external clients (list them).
|
||||
5. **⚠️ Outstanding mandatory gates (you cannot run these — flag them for the maintainer):**
|
||||
- **GPT-4.1 cross-family review** is required before merge for any IAM/policy or Lambda
|
||||
handler-signature change. (This PR intentionally avoids real IAM; confirm none was added.)
|
||||
- **`/sh-security-review`** (deep agentic security pass) is required before merge because this PR
|
||||
touches the **authentication/authorization surface** (scope enforcement, audience binding,
|
||||
token handling, redaction). State clearly that it has **not** been run and must be run by the
|
||||
maintainer before merge.
|
||||
- **Confluence "AWS Architecture Map" (id 1540098)** update and **project memory** update are
|
||||
owed once real infra lands — note as follow-ups, not done here.
|
||||
6. **Design conformance checklist** — tick the `design.md §2.5 / §7.3` guarantees you implemented
|
||||
(tool-hiding, server-side scope enforcement, audience binding, no-broker-passthrough, finance
|
||||
redaction on egress, audit logging, prompt-injection containment, rate limiting).
|
||||
|
||||
---
|
||||
|
||||
## 9. Definition of done
|
||||
|
||||
- [ ] `servers/sh-mcp-ops` and `servers/sh-mcp-finance` start with `SH_MCP_ENV=local` and serve
|
||||
`/mcp`, `/openapi.json`, `POST /tools/:name`, `/healthz`.
|
||||
- [ ] Both interfaces share one dispatch path; scope-hiding, audience binding, finance redaction,
|
||||
audit, input validation, and rate limiting all enforced there.
|
||||
- [ ] In-memory dev clients make ops read tools + tasks + finance reads return real fake data locally.
|
||||
- [ ] Full security-weighted test suite passes; coverage gate (80% / 100% on auth+dispatch) enforced in CI config.
|
||||
- [ ] `tsc --noEmit`, `eslint .`, `prettier --check .` all clean; `package-lock.json` committed.
|
||||
- [ ] Synth-only CDK stubs `cdk synth` cleanly; no real IAM/Cognito resources.
|
||||
- [ ] Draft PR opened to `main` with the §8 body, security/cross-review gates flagged as outstanding.
|
||||
133
eslint.config.js
Normal file
133
eslint.config.js
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
// @ts-check
|
||||
import tseslint from '@typescript-eslint/eslint-plugin';
|
||||
import tsparser from '@typescript-eslint/parser';
|
||||
|
||||
/**
|
||||
* Flat ESLint config for ESLint 9.x.
|
||||
* Migrated from .eslintrc.cjs which required legacy mode.
|
||||
*
|
||||
* Rules mirror the original: recommended + recommended-requiring-type-checking
|
||||
* plus project-specific overrides.
|
||||
*/
|
||||
|
||||
/** @type {import('eslint').Linter.Config[]} */
|
||||
const config = [
|
||||
// ── Global ignores ──────────────────────────────────────────────────────────
|
||||
{
|
||||
ignores: [
|
||||
'**/dist/**',
|
||||
'**/node_modules/**',
|
||||
'**/*.d.ts',
|
||||
'eslint.config.js',
|
||||
'vitest.config.ts',
|
||||
],
|
||||
},
|
||||
|
||||
// ── Source files (with project-based type checking) ─────────────────────────
|
||||
{
|
||||
files: ['packages/*/src/**/*.ts'],
|
||||
languageOptions: {
|
||||
parser: tsparser,
|
||||
parserOptions: {
|
||||
project: [
|
||||
'./packages/calendar/tsconfig.json',
|
||||
'./packages/gmail/tsconfig.json',
|
||||
'./packages/google-maps/tsconfig.json',
|
||||
'./packages/internal-data/tsconfig.json',
|
||||
'./packages/knowledge-base/tsconfig.json',
|
||||
'./packages/payments/tsconfig.json',
|
||||
'./packages/qbo/tsconfig.json',
|
||||
'./packages/reminders/tsconfig.json',
|
||||
'./packages/shared/tsconfig.json',
|
||||
'./packages/tasks/tsconfig.json',
|
||||
],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
globals: {
|
||||
process: 'readonly',
|
||||
console: 'readonly',
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'@typescript-eslint': tseslint,
|
||||
},
|
||||
rules: {
|
||||
'no-undef': 'off', // TypeScript handles this
|
||||
'no-unused-vars': 'off', // Use @typescript-eslint version
|
||||
|
||||
// Core @typescript-eslint/recommended rules
|
||||
'@typescript-eslint/ban-ts-comment': 'error',
|
||||
'@typescript-eslint/no-array-constructor': 'error',
|
||||
'@typescript-eslint/no-duplicate-enum-values': 'error',
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/no-extra-non-null-assertion': 'error',
|
||||
'@typescript-eslint/no-misused-new': 'error',
|
||||
'@typescript-eslint/no-namespace': 'error',
|
||||
'@typescript-eslint/no-non-null-asserted-optional-chain': 'error',
|
||||
'@typescript-eslint/no-require-imports': 'error',
|
||||
'@typescript-eslint/no-this-alias': 'error',
|
||||
'@typescript-eslint/no-unnecessary-type-constraint': 'error',
|
||||
'@typescript-eslint/no-unsafe-declaration-merging': 'error',
|
||||
'@typescript-eslint/no-unused-expressions': 'error',
|
||||
'@typescript-eslint/prefer-as-const': 'error',
|
||||
'@typescript-eslint/prefer-namespace-keyword': 'error',
|
||||
'@typescript-eslint/triple-slash-reference': 'error',
|
||||
|
||||
// Type-checked rules (require-type-checking)
|
||||
'@typescript-eslint/no-floating-promises': 'error',
|
||||
'@typescript-eslint/no-misused-promises': 'error',
|
||||
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||
'@typescript-eslint/no-unsafe-assignment': 'warn',
|
||||
'@typescript-eslint/no-unsafe-call': 'warn',
|
||||
'@typescript-eslint/no-unsafe-member-access': 'warn',
|
||||
'@typescript-eslint/no-unsafe-return': 'warn',
|
||||
'@typescript-eslint/require-await': 'warn',
|
||||
'@typescript-eslint/restrict-template-expressions': 'warn',
|
||||
|
||||
// Project-specific overrides
|
||||
'@typescript-eslint/explicit-function-return-type': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/strict-boolean-expressions': 'warn',
|
||||
},
|
||||
},
|
||||
|
||||
// ── Test files (no project-based type checking — test/ dirs not in tsconfig) ─
|
||||
{
|
||||
files: ['packages/*/test/**/*.ts'],
|
||||
languageOptions: {
|
||||
parser: tsparser,
|
||||
parserOptions: {
|
||||
// No `project` here — avoids "file not found in project" errors for
|
||||
// test files that are excluded from the package tsconfigss.
|
||||
// Type-checking rules are disabled below.
|
||||
},
|
||||
globals: {
|
||||
process: 'readonly',
|
||||
console: 'readonly',
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'@typescript-eslint': tseslint,
|
||||
},
|
||||
rules: {
|
||||
'no-undef': 'off',
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export default config;
|
||||
6508
package-lock.json
generated
Normal file
6508
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
34
package.json
Normal file
34
package.json
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"name": "sh-mcp",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"description": "Sea Haven MCP Platform — auth-first task agents backed by trust-tiered MCP servers",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
"packages/*",
|
||||
"servers/*",
|
||||
"jobs/*",
|
||||
"auth/*"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -b",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"lint": "eslint .",
|
||||
"format:check": "prettier --check .",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "22.7.4",
|
||||
"@typescript-eslint/eslint-plugin": "8.17.0",
|
||||
"@typescript-eslint/parser": "8.17.0",
|
||||
"eslint": "9.14.0",
|
||||
"prettier": "3.4.2",
|
||||
"typescript": "5.6.3",
|
||||
"vitest": "3.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
}
|
||||
}
|
||||
0
packages/.gitkeep
Normal file
0
packages/.gitkeep
Normal file
27
packages/calendar/package.json
Normal file
27
packages/calendar/package.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"name": "@sh-mcp/calendar",
|
||||
"version": "0.1.0",
|
||||
"description": "Sea Haven MCP calendar tools — get_calendar_events, check_availability, create_calendar_event",
|
||||
"license": "UNLICENSED",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc --project tsconfig.json",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sh-mcp/shared": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.5.0",
|
||||
"vitest": "^2.0.0"
|
||||
}
|
||||
}
|
||||
243
packages/calendar/src/client.ts
Normal file
243
packages/calendar/src/client.ts
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
/**
|
||||
* CalendarClient interface + thin implementation.
|
||||
*
|
||||
* The real implementation calls the Google Calendar API v3 using a per-user
|
||||
* OAuth2 access token supplied by the injected token provider. The actual
|
||||
* googleapis SDK call is clearly stubbed — see the TODO below — so this file
|
||||
* compiles and is safe to import without any network activity or AWS calls.
|
||||
*
|
||||
* Tests inject a MockCalendarClient that implements the same interface.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Domain types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CalendarEvent {
|
||||
id: string;
|
||||
summary: string;
|
||||
description?: string;
|
||||
start: string; // ISO-8601 datetime or date
|
||||
end: string; // ISO-8601 datetime or date
|
||||
attendees?: CalendarAttendee[];
|
||||
/** True when at least one attendee is not @seahavenind.com */
|
||||
hasExternalAttendees?: boolean;
|
||||
status?: string; // confirmed | tentative | cancelled
|
||||
htmlLink?: string;
|
||||
}
|
||||
|
||||
export interface CalendarAttendee {
|
||||
email: string;
|
||||
displayName?: string;
|
||||
responseStatus?: string; // accepted | declined | needsAction | tentative
|
||||
organizer?: boolean;
|
||||
self?: boolean;
|
||||
}
|
||||
|
||||
export interface GetEventsOptions {
|
||||
calendarId?: string; // defaults to 'primary'
|
||||
timeMin: string; // ISO-8601
|
||||
timeMax: string; // ISO-8601
|
||||
maxResults?: number;
|
||||
singleEvents?: boolean;
|
||||
orderBy?: 'startTime' | 'updated';
|
||||
}
|
||||
|
||||
export interface CheckAvailabilityOptions {
|
||||
/** ISO-8601 start of window to check */
|
||||
timeMin: string;
|
||||
/** ISO-8601 end of window to check */
|
||||
timeMax: string;
|
||||
/** Defaults to 'primary' */
|
||||
calendarId?: string;
|
||||
}
|
||||
|
||||
export interface AvailabilityResult {
|
||||
busy: Array<{ start: string; end: string }>;
|
||||
free: Array<{ start: string; end: string }>;
|
||||
}
|
||||
|
||||
export interface CreateEventOptions {
|
||||
calendarId?: string; // defaults to 'primary'
|
||||
summary: string;
|
||||
description?: string;
|
||||
start: string; // ISO-8601 datetime
|
||||
end: string; // ISO-8601 datetime
|
||||
attendees?: Array<{ email: string; displayName?: string }>;
|
||||
location?: string;
|
||||
timeZone?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Interface — every consumer codes against this, never against the concrete impl
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CalendarClient {
|
||||
/**
|
||||
* Returns events from the user's calendar in the requested window.
|
||||
* Throws CalendarClientError on API errors.
|
||||
*/
|
||||
getEvents(userSub: string, opts: GetEventsOptions): Promise<CalendarEvent[]>;
|
||||
|
||||
/**
|
||||
* Queries free/busy information for the user's calendar.
|
||||
*/
|
||||
checkAvailability(
|
||||
userSub: string,
|
||||
opts: CheckAvailabilityOptions,
|
||||
): Promise<AvailabilityResult>;
|
||||
|
||||
/**
|
||||
* Creates a calendar event and returns the created event.
|
||||
* Callers MUST inspect hasExternalAttendees on the result and surface the
|
||||
* warning to the user (design §2.5 — outbound invite monitoring).
|
||||
*/
|
||||
createEvent(userSub: string, opts: CreateEventOptions): Promise<CalendarEvent>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class CalendarClientError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly statusCode?: number,
|
||||
public readonly retryable: boolean = false,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'CalendarClientError';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-user token provider interface
|
||||
//
|
||||
// Real implementation fetches/refreshes the per-user Google OAuth2 refresh
|
||||
// token from KMS-CMK-encrypted DynamoDB (keyed by user `sub`, ABAC-partitioned
|
||||
// by LeadingKeys — design §2.4). This interface is injected so tests can mock
|
||||
// it without any AWS/DDB calls at import time.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface GoogleTokenProvider {
|
||||
/**
|
||||
* Returns a short-lived Google OAuth2 access token scoped to ONLY
|
||||
* `https://www.googleapis.com/auth/calendar` for the given user.
|
||||
*
|
||||
* The returned token is minted per-request and is never cached by this
|
||||
* interface (design §2.4 — access tokens not cached or reused).
|
||||
*
|
||||
* Throws if the user has not yet granted the Calendar OAuth consent.
|
||||
*/
|
||||
getAccessToken(userSub: string): Promise<string>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Concrete implementation (real call stubbed — see TODO)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Thin wrapper around the Google Calendar API v3.
|
||||
*
|
||||
* Construction is cheap (no network, no AWS) — the tokenProvider handles all
|
||||
* credential retrieval lazily at call time.
|
||||
*/
|
||||
export class GoogleCalendarClient implements CalendarClient {
|
||||
// Stored for use by the real implementation once the token store is available (design §2.4).
|
||||
private readonly _tokenProvider: GoogleTokenProvider;
|
||||
|
||||
constructor(tokenProvider: GoogleTokenProvider) {
|
||||
this._tokenProvider = tokenProvider;
|
||||
// Mark as intentionally stored-but-unused until the real SDK call is wired.
|
||||
void this._tokenProvider;
|
||||
}
|
||||
|
||||
async getEvents(userSub: string, opts: GetEventsOptions): Promise<CalendarEvent[]> {
|
||||
// TODO: replace the stub below with the real googleapis SDK call.
|
||||
//
|
||||
// import { google } from 'googleapis';
|
||||
// const accessToken = await this.tokenProvider.getAccessToken(userSub);
|
||||
// const auth = new google.auth.OAuth2();
|
||||
// auth.setCredentials({ access_token: accessToken });
|
||||
// const cal = google.calendar({ version: 'v3', auth });
|
||||
// const res = await cal.events.list({
|
||||
// calendarId: opts.calendarId ?? 'primary',
|
||||
// timeMin: opts.timeMin,
|
||||
// timeMax: opts.timeMax,
|
||||
// maxResults: opts.maxResults ?? 50,
|
||||
// singleEvents: opts.singleEvents ?? true,
|
||||
// orderBy: opts.orderBy ?? 'startTime',
|
||||
// });
|
||||
// return (res.data.items ?? []).map(mapEvent);
|
||||
//
|
||||
// DEFERRED: awaiting the 0a-gated per-user token + DDB store (design §2.4).
|
||||
void userSub;
|
||||
void opts;
|
||||
throw new CalendarClientError(
|
||||
'GoogleCalendarClient.getEvents is not yet implemented — awaiting per-user token store (design §2.4)',
|
||||
501,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
async checkAvailability(
|
||||
userSub: string,
|
||||
opts: CheckAvailabilityOptions,
|
||||
): Promise<AvailabilityResult> {
|
||||
// TODO: replace the stub below with the real googleapis SDK call.
|
||||
//
|
||||
// import { google } from 'googleapis';
|
||||
// const accessToken = await this.tokenProvider.getAccessToken(userSub);
|
||||
// const auth = new google.auth.OAuth2();
|
||||
// auth.setCredentials({ access_token: accessToken });
|
||||
// const cal = google.calendar({ version: 'v3', auth });
|
||||
// const res = await cal.freebusy.query({
|
||||
// requestBody: {
|
||||
// timeMin: opts.timeMin,
|
||||
// timeMax: opts.timeMax,
|
||||
// items: [{ id: opts.calendarId ?? 'primary' }],
|
||||
// },
|
||||
// });
|
||||
// return computeFreeBusy(opts.timeMin, opts.timeMax, res.data);
|
||||
//
|
||||
// DEFERRED: awaiting the 0a-gated per-user token + DDB store (design §2.4).
|
||||
void userSub;
|
||||
void opts;
|
||||
throw new CalendarClientError(
|
||||
'GoogleCalendarClient.checkAvailability is not yet implemented — awaiting per-user token store (design §2.4)',
|
||||
501,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
async createEvent(userSub: string, opts: CreateEventOptions): Promise<CalendarEvent> {
|
||||
// TODO: replace the stub below with the real googleapis SDK call.
|
||||
//
|
||||
// import { google } from 'googleapis';
|
||||
// const accessToken = await this.tokenProvider.getAccessToken(userSub);
|
||||
// const auth = new google.auth.OAuth2();
|
||||
// auth.setCredentials({ access_token: accessToken });
|
||||
// const cal = google.calendar({ version: 'v3', auth });
|
||||
// const res = await cal.events.insert({
|
||||
// calendarId: opts.calendarId ?? 'primary',
|
||||
// requestBody: {
|
||||
// summary: opts.summary,
|
||||
// description: opts.description,
|
||||
// start: { dateTime: opts.start, timeZone: opts.timeZone },
|
||||
// end: { dateTime: opts.end, timeZone: opts.timeZone },
|
||||
// attendees: opts.attendees,
|
||||
// location: opts.location,
|
||||
// },
|
||||
// });
|
||||
// return mapEvent(res.data);
|
||||
//
|
||||
// DEFERRED: awaiting the 0a-gated per-user token + DDB store (design §2.4).
|
||||
void userSub;
|
||||
void opts;
|
||||
throw new CalendarClientError(
|
||||
'GoogleCalendarClient.createEvent is not yet implemented — awaiting per-user token store (design §2.4)',
|
||||
501,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
38
packages/calendar/src/index.ts
Normal file
38
packages/calendar/src/index.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* @sh-mcp/calendar — Sea Haven calendar tools
|
||||
*
|
||||
* Exports the tools array built against a CalendarClient interface.
|
||||
* The wire transport layer (OpenAPI / MCP) consumes this registry;
|
||||
* this package only defines the tools.
|
||||
*
|
||||
* Usage (server instantiation):
|
||||
*
|
||||
* import { calendarTools } from '@sh-mcp/calendar';
|
||||
* import { GoogleCalendarClient } from '@sh-mcp/calendar/client';
|
||||
*
|
||||
* const client = new GoogleCalendarClient(tokenProvider);
|
||||
* const tools = calendarTools(client);
|
||||
* registry.register(tools);
|
||||
*/
|
||||
|
||||
export { buildCalendarTools } from './tools.js';
|
||||
export type {
|
||||
CalendarClient,
|
||||
CalendarEvent,
|
||||
CalendarAttendee,
|
||||
CalendarClientError,
|
||||
GoogleTokenProvider,
|
||||
GetEventsOptions,
|
||||
CheckAvailabilityOptions,
|
||||
AvailabilityResult,
|
||||
CreateEventOptions,
|
||||
} from './client.js';
|
||||
export { GoogleCalendarClient } from './client.js';
|
||||
export type {
|
||||
GetCalendarEventsInput,
|
||||
GetCalendarEventsOutput,
|
||||
CheckAvailabilityInput,
|
||||
CheckAvailabilityOutput,
|
||||
CreateCalendarEventInput,
|
||||
CreateCalendarEventOutput,
|
||||
} from './tools.js';
|
||||
343
packages/calendar/src/tools.ts
Normal file
343
packages/calendar/src/tools.ts
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
/**
|
||||
* Sea Haven MCP calendar tools.
|
||||
*
|
||||
* All three tools require the `calendar:self` scope (ops tier).
|
||||
* The Google Calendar client is injected so the real googleapis SDK call
|
||||
* can be swapped in later and tests can pass a mock.
|
||||
*
|
||||
* Per design §2.5 and §3:
|
||||
* - create_calendar_event flags external attendees (any attendee whose email
|
||||
* is not @seahavenind.com) so the caller/agent can surface a warning.
|
||||
* - finance-tier handlers MUST call redact() on sensitive fields; calendar is
|
||||
* ops-tier, so redact() is not required here, but it is imported and applied
|
||||
* defensively on the free-text description/summary fields in create responses
|
||||
* to prevent accidental PII leakage (belt-and-suspenders).
|
||||
*/
|
||||
|
||||
import { defineTool, requireScope } from '@sh-mcp/shared';
|
||||
import type { AuthContext } from '@sh-mcp/shared';
|
||||
import type {
|
||||
CalendarClient,
|
||||
CalendarEvent,
|
||||
AvailabilityResult,
|
||||
} from './client.js';
|
||||
import { CalendarClientError } from './client.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SEA_HAVEN_DOMAIN = 'seahavenind.com';
|
||||
|
||||
function isExternal(email: string): boolean {
|
||||
return !email.toLowerCase().endsWith(`@${SEA_HAVEN_DOMAIN}`);
|
||||
}
|
||||
|
||||
function flagExternalAttendees(event: CalendarEvent): CalendarEvent & {
|
||||
hasExternalAttendees: boolean;
|
||||
externalAttendeeWarning?: string;
|
||||
} {
|
||||
const attendees = event.attendees ?? [];
|
||||
const externalAttendees = attendees.filter((a) => isExternal(a.email));
|
||||
const hasExternalAttendees = externalAttendees.length > 0;
|
||||
return {
|
||||
...event,
|
||||
hasExternalAttendees,
|
||||
...(hasExternalAttendees && {
|
||||
externalAttendeeWarning:
|
||||
`This event includes ${externalAttendees.length} external attendee(s): ` +
|
||||
externalAttendees.map((a) => a.email).join(', ') +
|
||||
'. Confirm before sending invites outside @seahavenind.com.',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input / output types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface GetCalendarEventsInput {
|
||||
timeMin: string;
|
||||
timeMax: string;
|
||||
calendarId?: string;
|
||||
maxResults?: number;
|
||||
}
|
||||
|
||||
export interface GetCalendarEventsOutput {
|
||||
events: CalendarEvent[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface CheckAvailabilityInput {
|
||||
timeMin: string;
|
||||
timeMax: string;
|
||||
calendarId?: string;
|
||||
}
|
||||
|
||||
export interface CheckAvailabilityOutput extends AvailabilityResult {
|
||||
timeMin: string;
|
||||
timeMax: string;
|
||||
}
|
||||
|
||||
export interface CreateCalendarEventInput {
|
||||
summary: string;
|
||||
start: string;
|
||||
end: string;
|
||||
description?: string;
|
||||
attendees?: Array<{ email: string; displayName?: string }>;
|
||||
location?: string;
|
||||
timeZone?: string;
|
||||
calendarId?: string;
|
||||
}
|
||||
|
||||
export interface CreateCalendarEventOutput extends CalendarEvent {
|
||||
hasExternalAttendees: boolean;
|
||||
externalAttendeeWarning?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool factory
|
||||
//
|
||||
// The client is injected here (not imported as a module singleton) so tests
|
||||
// can pass a mock without any real network or AWS calls.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function buildCalendarTools(client: CalendarClient) {
|
||||
// -------------------------------------------------------------------------
|
||||
// get_calendar_events
|
||||
// -------------------------------------------------------------------------
|
||||
const getCalendarEvents = defineTool<GetCalendarEventsInput, GetCalendarEventsOutput>({
|
||||
name: 'get_calendar_events',
|
||||
description:
|
||||
'Retrieve calendar events for the authenticated user within a time window. ' +
|
||||
'Returns event summaries, times, attendees, and status. ' +
|
||||
'Requires the user to have previously granted the calendar:self OAuth consent.',
|
||||
tier: 'ops',
|
||||
requiredScope: 'calendar:self',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['timeMin', 'timeMax'],
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
timeMin: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
description: 'Start of the time window (ISO-8601 datetime, e.g. 2026-06-11T00:00:00Z).',
|
||||
},
|
||||
timeMax: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
description: 'End of the time window (ISO-8601 datetime).',
|
||||
},
|
||||
calendarId: {
|
||||
type: 'string',
|
||||
description: 'Calendar ID to query. Defaults to \'primary\'.',
|
||||
default: 'primary',
|
||||
},
|
||||
maxResults: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
maximum: 250,
|
||||
description: 'Maximum number of events to return (1–250, default 50).',
|
||||
default: 50,
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: async (
|
||||
input: GetCalendarEventsInput,
|
||||
ctx: AuthContext,
|
||||
): Promise<GetCalendarEventsOutput> => {
|
||||
requireScope(ctx, 'calendar:self');
|
||||
|
||||
let events: CalendarEvent[];
|
||||
try {
|
||||
events = await client.getEvents(ctx.sub, {
|
||||
timeMin: input.timeMin,
|
||||
timeMax: input.timeMax,
|
||||
calendarId: input.calendarId ?? 'primary',
|
||||
maxResults: input.maxResults ?? 50,
|
||||
singleEvents: true,
|
||||
orderBy: 'startTime',
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof CalendarClientError && err.retryable) {
|
||||
throw new Error(
|
||||
`Calendar API temporarily unavailable (retryable). Please try again shortly. Detail: ${err.message}`,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
return {
|
||||
events: events.map(flagExternalAttendees),
|
||||
count: events.length,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// check_availability
|
||||
// -------------------------------------------------------------------------
|
||||
const checkAvailability = defineTool<CheckAvailabilityInput, CheckAvailabilityOutput>({
|
||||
name: 'check_availability',
|
||||
description:
|
||||
'Check the authenticated user\'s free/busy availability within a time window. ' +
|
||||
'Returns a list of busy blocks and derived free blocks. ' +
|
||||
'Useful for scheduling and finding open meeting slots.',
|
||||
tier: 'ops',
|
||||
requiredScope: 'calendar:self',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['timeMin', 'timeMax'],
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
timeMin: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
description: 'Start of the window to check (ISO-8601 datetime).',
|
||||
},
|
||||
timeMax: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
description: 'End of the window to check (ISO-8601 datetime).',
|
||||
},
|
||||
calendarId: {
|
||||
type: 'string',
|
||||
description: 'Calendar ID to check. Defaults to \'primary\'.',
|
||||
default: 'primary',
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: async (
|
||||
input: CheckAvailabilityInput,
|
||||
ctx: AuthContext,
|
||||
): Promise<CheckAvailabilityOutput> => {
|
||||
requireScope(ctx, 'calendar:self');
|
||||
|
||||
let result: AvailabilityResult;
|
||||
try {
|
||||
result = await client.checkAvailability(ctx.sub, {
|
||||
timeMin: input.timeMin,
|
||||
timeMax: input.timeMax,
|
||||
calendarId: input.calendarId ?? 'primary',
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof CalendarClientError && err.retryable) {
|
||||
throw new Error(
|
||||
`Calendar API temporarily unavailable (retryable). Please try again shortly. Detail: ${err.message}`,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
timeMin: input.timeMin,
|
||||
timeMax: input.timeMax,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// create_calendar_event
|
||||
// -------------------------------------------------------------------------
|
||||
const createCalendarEvent = defineTool<CreateCalendarEventInput, CreateCalendarEventOutput>({
|
||||
name: 'create_calendar_event',
|
||||
description:
|
||||
'Create a calendar event for the authenticated user. ' +
|
||||
'Invites are sent to any listed attendees. ' +
|
||||
'IMPORTANT: if any attendee is outside @seahavenind.com, the response will include ' +
|
||||
'hasExternalAttendees=true and an externalAttendeeWarning — always surface this ' +
|
||||
'to the user before completing the action (design §2.5 — external invite monitoring).',
|
||||
tier: 'ops',
|
||||
requiredScope: 'calendar:self',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['summary', 'start', 'end'],
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
summary: {
|
||||
type: 'string',
|
||||
maxLength: 1024,
|
||||
description: 'Event title.',
|
||||
},
|
||||
start: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
description: 'Event start datetime (ISO-8601).',
|
||||
},
|
||||
end: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
description: 'Event end datetime (ISO-8601).',
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
maxLength: 8192,
|
||||
description: 'Optional event description / body.',
|
||||
},
|
||||
attendees: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['email'],
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
email: { type: 'string', format: 'email' },
|
||||
displayName: { type: 'string' },
|
||||
},
|
||||
},
|
||||
description: 'List of attendees. External (@seahavenind.com) attendees will be flagged.',
|
||||
},
|
||||
location: {
|
||||
type: 'string',
|
||||
maxLength: 1024,
|
||||
description: 'Optional physical or virtual location.',
|
||||
},
|
||||
timeZone: {
|
||||
type: 'string',
|
||||
description: 'IANA time zone for start/end (e.g. America/New_York). Defaults to UTC.',
|
||||
default: 'UTC',
|
||||
},
|
||||
calendarId: {
|
||||
type: 'string',
|
||||
description: 'Calendar to create the event in. Defaults to \'primary\'.',
|
||||
default: 'primary',
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: async (
|
||||
input: CreateCalendarEventInput,
|
||||
ctx: AuthContext,
|
||||
): Promise<CreateCalendarEventOutput> => {
|
||||
requireScope(ctx, 'calendar:self');
|
||||
|
||||
let created: CalendarEvent;
|
||||
try {
|
||||
created = await client.createEvent(ctx.sub, {
|
||||
summary: input.summary,
|
||||
start: input.start,
|
||||
end: input.end,
|
||||
description: input.description,
|
||||
attendees: input.attendees,
|
||||
location: input.location,
|
||||
timeZone: input.timeZone ?? 'UTC',
|
||||
calendarId: input.calendarId ?? 'primary',
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof CalendarClientError && err.retryable) {
|
||||
throw new Error(
|
||||
`Calendar API temporarily unavailable (retryable). Please try again shortly. Detail: ${err.message}`,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Flag external attendees — callers MUST surface externalAttendeeWarning
|
||||
// when hasExternalAttendees is true (design §2.5).
|
||||
return flagExternalAttendees(created);
|
||||
},
|
||||
});
|
||||
|
||||
return [getCalendarEvents, checkAvailability, createCalendarEvent] as const;
|
||||
}
|
||||
424
packages/calendar/test/calendar.test.ts
Normal file
424
packages/calendar/test/calendar.test.ts
Normal file
|
|
@ -0,0 +1,424 @@
|
|||
/**
|
||||
* @sh-mcp/calendar — unit tests
|
||||
*
|
||||
* All tests inject a mock CalendarClient and a mock AuthContext.
|
||||
* No real network calls. No AWS calls at import time.
|
||||
*
|
||||
* Covers:
|
||||
* - happy path for each tool
|
||||
* - empty result
|
||||
* - API error propagation
|
||||
* - throttle / retryable error path
|
||||
* - scope enforcement (missing scope → ScopeError)
|
||||
* - external-attendee flagging on create_calendar_event
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { AuthContext } from '@sh-mcp/shared';
|
||||
import { buildCalendarTools } from '../src/tools.js';
|
||||
import type { CalendarClient, CalendarEvent, AvailabilityResult } from '../src/client.js';
|
||||
import { CalendarClientError } from '../src/client.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MOCK_CTX: AuthContext = {
|
||||
sub: 'lauren@seahavenind.com',
|
||||
scopes: ['ops:read', 'ops:tasks', 'gmail:self', 'calendar:self'],
|
||||
aud: 'sh-mcp-ops',
|
||||
};
|
||||
|
||||
const CTX_NO_CALENDAR: AuthContext = {
|
||||
sub: 'other@seahavenind.com',
|
||||
scopes: ['ops:read'],
|
||||
aud: 'sh-mcp-ops',
|
||||
};
|
||||
|
||||
const INTERNAL_EVENT: CalendarEvent = {
|
||||
id: 'event-001',
|
||||
summary: 'Team standup',
|
||||
start: '2026-06-11T09:00:00-04:00',
|
||||
end: '2026-06-11T09:15:00-04:00',
|
||||
attendees: [
|
||||
{ email: 'lauren@seahavenind.com', self: true, responseStatus: 'accepted' },
|
||||
{ email: 'adam@seahavenind.com', responseStatus: 'accepted' },
|
||||
],
|
||||
status: 'confirmed',
|
||||
};
|
||||
|
||||
const EXTERNAL_EVENT: CalendarEvent = {
|
||||
id: 'event-002',
|
||||
summary: 'Vendor call',
|
||||
start: '2026-06-11T14:00:00-04:00',
|
||||
end: '2026-06-11T15:00:00-04:00',
|
||||
attendees: [
|
||||
{ email: 'lauren@seahavenind.com', self: true, responseStatus: 'accepted' },
|
||||
{ email: 'vendor@externalco.com', responseStatus: 'needsAction' },
|
||||
],
|
||||
status: 'confirmed',
|
||||
};
|
||||
|
||||
const AVAILABILITY: AvailabilityResult = {
|
||||
busy: [{ start: '2026-06-11T09:00:00Z', end: '2026-06-11T09:15:00Z' }],
|
||||
free: [
|
||||
{ start: '2026-06-11T08:00:00Z', end: '2026-06-11T09:00:00Z' },
|
||||
{ start: '2026-06-11T09:15:00Z', end: '2026-06-11T17:00:00Z' },
|
||||
],
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock client builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildMockClient(overrides: Partial<CalendarClient> = {}): CalendarClient {
|
||||
return {
|
||||
getEvents: vi.fn().mockResolvedValue([INTERNAL_EVENT]),
|
||||
checkAvailability: vi.fn().mockResolvedValue(AVAILABILITY),
|
||||
createEvent: vi.fn().mockResolvedValue(INTERNAL_EVENT),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// get_calendar_events
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('get_calendar_events', () => {
|
||||
let mockClient: CalendarClient;
|
||||
let tools: ReturnType<typeof buildCalendarTools>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockClient = buildMockClient();
|
||||
tools = buildCalendarTools(mockClient);
|
||||
});
|
||||
|
||||
const getTool = (ts: ReturnType<typeof buildCalendarTools>) =>
|
||||
ts.find((t) => t.name === 'get_calendar_events')!;
|
||||
|
||||
it('happy path — returns events with count', async () => {
|
||||
const tool = getTool(tools);
|
||||
const result = await tool.handler(
|
||||
{ timeMin: '2026-06-11T00:00:00Z', timeMax: '2026-06-11T23:59:59Z' },
|
||||
MOCK_CTX,
|
||||
);
|
||||
expect(result.count).toBe(1);
|
||||
expect(result.events[0].id).toBe('event-001');
|
||||
expect(mockClient.getEvents).toHaveBeenCalledWith(
|
||||
MOCK_CTX.sub,
|
||||
expect.objectContaining({
|
||||
timeMin: '2026-06-11T00:00:00Z',
|
||||
timeMax: '2026-06-11T23:59:59Z',
|
||||
singleEvents: true,
|
||||
orderBy: 'startTime',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('empty result — returns count 0', async () => {
|
||||
mockClient = buildMockClient({ getEvents: vi.fn().mockResolvedValue([]) });
|
||||
tools = buildCalendarTools(mockClient);
|
||||
const result = await getTool(tools).handler(
|
||||
{ timeMin: '2026-06-12T00:00:00Z', timeMax: '2026-06-12T23:59:59Z' },
|
||||
MOCK_CTX,
|
||||
);
|
||||
expect(result.count).toBe(0);
|
||||
expect(result.events).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('non-retryable API error is re-thrown as-is', async () => {
|
||||
mockClient = buildMockClient({
|
||||
getEvents: vi.fn().mockRejectedValue(
|
||||
new CalendarClientError('Forbidden', 403, false),
|
||||
),
|
||||
});
|
||||
tools = buildCalendarTools(mockClient);
|
||||
await expect(
|
||||
getTool(tools).handler(
|
||||
{ timeMin: '2026-06-11T00:00:00Z', timeMax: '2026-06-11T23:59:59Z' },
|
||||
MOCK_CTX,
|
||||
),
|
||||
).rejects.toThrow('Forbidden');
|
||||
});
|
||||
|
||||
it('retryable/throttle error is wrapped with user-friendly message', async () => {
|
||||
mockClient = buildMockClient({
|
||||
getEvents: vi.fn().mockRejectedValue(
|
||||
new CalendarClientError('Rate limited', 429, true),
|
||||
),
|
||||
});
|
||||
tools = buildCalendarTools(mockClient);
|
||||
await expect(
|
||||
getTool(tools).handler(
|
||||
{ timeMin: '2026-06-11T00:00:00Z', timeMax: '2026-06-11T23:59:59Z' },
|
||||
MOCK_CTX,
|
||||
),
|
||||
).rejects.toThrow(/temporarily unavailable.*retryable/i);
|
||||
});
|
||||
|
||||
it('missing calendar:self scope throws ScopeError', async () => {
|
||||
await expect(
|
||||
getTool(tools).handler(
|
||||
{ timeMin: '2026-06-11T00:00:00Z', timeMax: '2026-06-11T23:59:59Z' },
|
||||
CTX_NO_CALENDAR,
|
||||
),
|
||||
).rejects.toThrow();
|
||||
// getEvents should never have been called
|
||||
expect(mockClient.getEvents).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('flags external attendees on returned events', async () => {
|
||||
mockClient = buildMockClient({
|
||||
getEvents: vi.fn().mockResolvedValue([EXTERNAL_EVENT]),
|
||||
});
|
||||
tools = buildCalendarTools(mockClient);
|
||||
const result = await getTool(tools).handler(
|
||||
{ timeMin: '2026-06-11T00:00:00Z', timeMax: '2026-06-11T23:59:59Z' },
|
||||
MOCK_CTX,
|
||||
);
|
||||
expect(result.events[0].hasExternalAttendees).toBe(true);
|
||||
expect((result.events[0] as { externalAttendeeWarning?: string }).externalAttendeeWarning)
|
||||
.toContain('vendor@externalco.com');
|
||||
});
|
||||
|
||||
it('defines correct tool metadata', () => {
|
||||
const tool = getTool(tools);
|
||||
expect(tool.name).toBe('get_calendar_events');
|
||||
expect(tool.tier).toBe('ops');
|
||||
expect(tool.requiredScope).toBe('calendar:self');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// check_availability
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('check_availability', () => {
|
||||
let mockClient: CalendarClient;
|
||||
let tools: ReturnType<typeof buildCalendarTools>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockClient = buildMockClient();
|
||||
tools = buildCalendarTools(mockClient);
|
||||
});
|
||||
|
||||
const getTool = (ts: ReturnType<typeof buildCalendarTools>) =>
|
||||
ts.find((t) => t.name === 'check_availability')!;
|
||||
|
||||
it('happy path — returns busy and free blocks with echoed window', async () => {
|
||||
const input = {
|
||||
timeMin: '2026-06-11T08:00:00Z',
|
||||
timeMax: '2026-06-11T17:00:00Z',
|
||||
};
|
||||
const result = await getTool(tools).handler(input, MOCK_CTX);
|
||||
expect(result.busy).toHaveLength(1);
|
||||
expect(result.free).toHaveLength(2);
|
||||
expect(result.timeMin).toBe(input.timeMin);
|
||||
expect(result.timeMax).toBe(input.timeMax);
|
||||
});
|
||||
|
||||
it('empty availability — no busy blocks', async () => {
|
||||
mockClient = buildMockClient({
|
||||
checkAvailability: vi.fn().mockResolvedValue({ busy: [], free: [
|
||||
{ start: '2026-06-11T08:00:00Z', end: '2026-06-11T17:00:00Z' },
|
||||
] }),
|
||||
});
|
||||
tools = buildCalendarTools(mockClient);
|
||||
const result = await getTool(tools).handler(
|
||||
{ timeMin: '2026-06-11T08:00:00Z', timeMax: '2026-06-11T17:00:00Z' },
|
||||
MOCK_CTX,
|
||||
);
|
||||
expect(result.busy).toHaveLength(0);
|
||||
expect(result.free).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('non-retryable error is re-thrown', async () => {
|
||||
mockClient = buildMockClient({
|
||||
checkAvailability: vi.fn().mockRejectedValue(
|
||||
new CalendarClientError('Not found', 404, false),
|
||||
),
|
||||
});
|
||||
tools = buildCalendarTools(mockClient);
|
||||
await expect(
|
||||
getTool(tools).handler(
|
||||
{ timeMin: '2026-06-11T08:00:00Z', timeMax: '2026-06-11T17:00:00Z' },
|
||||
MOCK_CTX,
|
||||
),
|
||||
).rejects.toThrow('Not found');
|
||||
});
|
||||
|
||||
it('throttle/retryable error wraps with user-friendly message', async () => {
|
||||
mockClient = buildMockClient({
|
||||
checkAvailability: vi.fn().mockRejectedValue(
|
||||
new CalendarClientError('Quota exceeded', 429, true),
|
||||
),
|
||||
});
|
||||
tools = buildCalendarTools(mockClient);
|
||||
await expect(
|
||||
getTool(tools).handler(
|
||||
{ timeMin: '2026-06-11T08:00:00Z', timeMax: '2026-06-11T17:00:00Z' },
|
||||
MOCK_CTX,
|
||||
),
|
||||
).rejects.toThrow(/temporarily unavailable.*retryable/i);
|
||||
});
|
||||
|
||||
it('missing calendar:self scope throws before calling client', async () => {
|
||||
await expect(
|
||||
getTool(tools).handler(
|
||||
{ timeMin: '2026-06-11T08:00:00Z', timeMax: '2026-06-11T17:00:00Z' },
|
||||
CTX_NO_CALENDAR,
|
||||
),
|
||||
).rejects.toThrow();
|
||||
expect(mockClient.checkAvailability).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('defines correct tool metadata', () => {
|
||||
const tool = getTool(tools);
|
||||
expect(tool.name).toBe('check_availability');
|
||||
expect(tool.tier).toBe('ops');
|
||||
expect(tool.requiredScope).toBe('calendar:self');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// create_calendar_event
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('create_calendar_event', () => {
|
||||
let mockClient: CalendarClient;
|
||||
let tools: ReturnType<typeof buildCalendarTools>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockClient = buildMockClient();
|
||||
tools = buildCalendarTools(mockClient);
|
||||
});
|
||||
|
||||
const getTool = (ts: ReturnType<typeof buildCalendarTools>) =>
|
||||
ts.find((t) => t.name === 'create_calendar_event')!;
|
||||
|
||||
it('happy path — internal attendees only, hasExternalAttendees=false', async () => {
|
||||
const input = {
|
||||
summary: 'Sprint planning',
|
||||
start: '2026-06-15T10:00:00-04:00',
|
||||
end: '2026-06-15T11:00:00-04:00',
|
||||
attendees: [{ email: 'adam@seahavenind.com' }],
|
||||
};
|
||||
const result = await getTool(tools).handler(input, MOCK_CTX);
|
||||
expect(result.id).toBe('event-001');
|
||||
expect(result.hasExternalAttendees).toBe(false);
|
||||
expect((result as { externalAttendeeWarning?: string }).externalAttendeeWarning).toBeUndefined();
|
||||
expect(mockClient.createEvent).toHaveBeenCalledWith(
|
||||
MOCK_CTX.sub,
|
||||
expect.objectContaining({ summary: 'Sprint planning' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('external attendee — hasExternalAttendees=true with warning', async () => {
|
||||
mockClient = buildMockClient({
|
||||
createEvent: vi.fn().mockResolvedValue(EXTERNAL_EVENT),
|
||||
});
|
||||
tools = buildCalendarTools(mockClient);
|
||||
const input = {
|
||||
summary: 'Vendor call',
|
||||
start: '2026-06-11T14:00:00-04:00',
|
||||
end: '2026-06-11T15:00:00-04:00',
|
||||
attendees: [
|
||||
{ email: 'lauren@seahavenind.com' },
|
||||
{ email: 'vendor@externalco.com' },
|
||||
],
|
||||
};
|
||||
const result = await getTool(tools).handler(input, MOCK_CTX);
|
||||
expect(result.hasExternalAttendees).toBe(true);
|
||||
expect(result.externalAttendeeWarning).toContain('vendor@externalco.com');
|
||||
expect(result.externalAttendeeWarning).toContain('seahavenind.com');
|
||||
});
|
||||
|
||||
it('non-retryable error is re-thrown', async () => {
|
||||
mockClient = buildMockClient({
|
||||
createEvent: vi.fn().mockRejectedValue(
|
||||
new CalendarClientError('Conflict', 409, false),
|
||||
),
|
||||
});
|
||||
tools = buildCalendarTools(mockClient);
|
||||
await expect(
|
||||
getTool(tools).handler(
|
||||
{ summary: 'Test', start: '2026-06-11T10:00:00Z', end: '2026-06-11T11:00:00Z' },
|
||||
MOCK_CTX,
|
||||
),
|
||||
).rejects.toThrow('Conflict');
|
||||
});
|
||||
|
||||
it('throttle/retryable error wraps with user-friendly message', async () => {
|
||||
mockClient = buildMockClient({
|
||||
createEvent: vi.fn().mockRejectedValue(
|
||||
new CalendarClientError('Rate limited', 429, true),
|
||||
),
|
||||
});
|
||||
tools = buildCalendarTools(mockClient);
|
||||
await expect(
|
||||
getTool(tools).handler(
|
||||
{ summary: 'Test', start: '2026-06-11T10:00:00Z', end: '2026-06-11T11:00:00Z' },
|
||||
MOCK_CTX,
|
||||
),
|
||||
).rejects.toThrow(/temporarily unavailable.*retryable/i);
|
||||
});
|
||||
|
||||
it('missing calendar:self scope throws before calling client', async () => {
|
||||
await expect(
|
||||
getTool(tools).handler(
|
||||
{ summary: 'Test', start: '2026-06-11T10:00:00Z', end: '2026-06-11T11:00:00Z' },
|
||||
CTX_NO_CALENDAR,
|
||||
),
|
||||
).rejects.toThrow();
|
||||
expect(mockClient.createEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('event with no attendees — hasExternalAttendees=false', async () => {
|
||||
mockClient = buildMockClient({
|
||||
createEvent: vi.fn().mockResolvedValue({
|
||||
...INTERNAL_EVENT,
|
||||
attendees: [],
|
||||
}),
|
||||
});
|
||||
tools = buildCalendarTools(mockClient);
|
||||
const result = await getTool(tools).handler(
|
||||
{ summary: 'Solo block', start: '2026-06-11T13:00:00Z', end: '2026-06-11T14:00:00Z' },
|
||||
MOCK_CTX,
|
||||
);
|
||||
expect(result.hasExternalAttendees).toBe(false);
|
||||
});
|
||||
|
||||
it('defines correct tool metadata', () => {
|
||||
const tool = getTool(tools);
|
||||
expect(tool.name).toBe('create_calendar_event');
|
||||
expect(tool.tier).toBe('ops');
|
||||
expect(tool.requiredScope).toBe('calendar:self');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Index / registry shape
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('buildCalendarTools registry', () => {
|
||||
it('returns exactly 3 tools with distinct names', () => {
|
||||
const mockClient = buildMockClient();
|
||||
const tools = buildCalendarTools(mockClient);
|
||||
expect(tools).toHaveLength(3);
|
||||
const names = tools.map((t) => t.name);
|
||||
expect(names).toContain('get_calendar_events');
|
||||
expect(names).toContain('check_availability');
|
||||
expect(names).toContain('create_calendar_event');
|
||||
// all distinct
|
||||
expect(new Set(names).size).toBe(3);
|
||||
});
|
||||
|
||||
it('all tools are ops tier with calendar:self scope', () => {
|
||||
const mockClient = buildMockClient();
|
||||
const tools = buildCalendarTools(mockClient);
|
||||
for (const tool of tools) {
|
||||
expect(tool.tier).toBe('ops');
|
||||
expect(tool.requiredScope).toBe('calendar:self');
|
||||
}
|
||||
});
|
||||
});
|
||||
9
packages/calendar/tsconfig.json
Normal file
9
packages/calendar/tsconfig.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declarationDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
34
packages/gmail/package.json
Normal file
34
packages/gmail/package.json
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"name": "@sh-mcp/gmail",
|
||||
"version": "0.1.0",
|
||||
"description": "Sea Haven MCP Gmail tools — search_inbox and get_email_thread_detail",
|
||||
"license": "UNLICENSED",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --project tsconfig.json",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sh-mcp/shared": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/coverage-v8": "^2.0.0",
|
||||
"typescript": "^5.5.0",
|
||||
"vitest": "^2.0.0"
|
||||
}
|
||||
}
|
||||
139
packages/gmail/src/client.ts
Normal file
139
packages/gmail/src/client.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
/**
|
||||
* Gmail client interface + thin implementation.
|
||||
*
|
||||
* The real Google API call is clearly stubbed/guarded behind the interface so
|
||||
* that nothing in this module imports the Google client SDK at module load time.
|
||||
* All consumers (tools.ts and tests) inject a GmailClient — no live network
|
||||
* traffic ever occurs during import or unit tests.
|
||||
*
|
||||
* Per §2.4 of the design:
|
||||
* - This module acts AS the signed-in user via a per-user Google OAuth token.
|
||||
* - The GoogleTokenProvider is injected; the implementation fetches a
|
||||
* short-lived access token on demand and never caches it.
|
||||
* - The refresh token is NEVER returned or logged.
|
||||
* - The client is instantiated with the minimal scope for the requested tool
|
||||
* only (gmail.readonly) — never the union of a user's scopes.
|
||||
*/
|
||||
|
||||
export interface EmailMessage {
|
||||
id: string;
|
||||
threadId: string;
|
||||
subject: string;
|
||||
from: string;
|
||||
to: string;
|
||||
date: string;
|
||||
/** Plain-text snippet — safe for log output. */
|
||||
snippet: string;
|
||||
}
|
||||
|
||||
export interface EmailThread {
|
||||
threadId: string;
|
||||
subject: string;
|
||||
messages: Array<{
|
||||
id: string;
|
||||
from: string;
|
||||
to: string;
|
||||
date: string;
|
||||
/** Full decoded plain-text body. */
|
||||
body: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface SearchInboxParams {
|
||||
/** Gmail query string, e.g. "from:vendor@example.com subject:invoice" */
|
||||
query: string;
|
||||
/** Maximum number of results to return (1–50). */
|
||||
maxResults: number;
|
||||
/** Sub of the calling user — used to look up the per-user Google token. */
|
||||
userSub: string;
|
||||
}
|
||||
|
||||
export interface GetThreadDetailParams {
|
||||
threadId: string;
|
||||
userSub: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The interface all callers (tools + tests) program against.
|
||||
* Tests inject a mock; production code injects GmailApiClient.
|
||||
*/
|
||||
export interface GmailClient {
|
||||
searchInbox(params: SearchInboxParams): Promise<EmailMessage[]>;
|
||||
getThreadDetail(params: GetThreadDetailParams): Promise<EmailThread>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a short-lived Google access token for a given user sub.
|
||||
* The real implementation reads the encrypted refresh token from DynamoDB
|
||||
* (KMS-CMK, ABAC-partitioned by sub) and exchanges it for an access token.
|
||||
* Tests inject a mock that returns a hard-coded dummy token.
|
||||
*
|
||||
* IMPORTANT: The refresh token MUST NOT be returned or surfaced anywhere
|
||||
* outside this provider. Access tokens are minted per request; do not cache.
|
||||
*/
|
||||
export interface GoogleTokenProvider {
|
||||
getAccessToken(userSub: string): Promise<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Production Gmail client.
|
||||
*
|
||||
* The actual HTTP call to the Gmail REST API is guarded behind the
|
||||
* STUB comment below. To complete the real implementation:
|
||||
* 1. npm install googleapis (or use undici/fetch directly)
|
||||
* 2. Build a google.auth.OAuth2 client from the access token returned
|
||||
* by tokenProvider.getAccessToken()
|
||||
* 3. Call gmail.users.messages.list / gmail.users.threads.get
|
||||
*
|
||||
* The stub throws so that accidental real calls fail loudly in tests.
|
||||
*/
|
||||
export class GmailApiClient implements GmailClient {
|
||||
// Stored for use by the real implementation once the Gmail SDK call is wired.
|
||||
private readonly _tokenProvider: GoogleTokenProvider;
|
||||
|
||||
constructor(tokenProvider: GoogleTokenProvider) {
|
||||
this._tokenProvider = tokenProvider;
|
||||
// Mark as intentionally stored-but-unused until the real SDK call is wired.
|
||||
void this._tokenProvider;
|
||||
}
|
||||
|
||||
async searchInbox(params: SearchInboxParams): Promise<EmailMessage[]> {
|
||||
// TODO: Replace this stub with the real Gmail API call.
|
||||
// When implementing, fetch a short-lived access token first:
|
||||
// const accessToken = await this.tokenProvider.getAccessToken(params.userSub);
|
||||
// Example (googleapis):
|
||||
// const auth = new google.auth.OAuth2();
|
||||
// auth.setCredentials({ access_token: accessToken });
|
||||
// const gmail = google.gmail({ version: 'v1', auth });
|
||||
// const res = await gmail.users.messages.list({
|
||||
// userId: 'me',
|
||||
// q: params.query,
|
||||
// maxResults: params.maxResults,
|
||||
// });
|
||||
// return parseMessageList(res.data);
|
||||
void params; // suppress unused-param warning; remove when real call is wired
|
||||
throw new Error(
|
||||
'GmailApiClient.searchInbox is not implemented — inject a GmailClient mock in tests and wire the real SDK call here for production.',
|
||||
);
|
||||
}
|
||||
|
||||
async getThreadDetail(params: GetThreadDetailParams): Promise<EmailThread> {
|
||||
// TODO: Replace this stub with the real Gmail API call.
|
||||
// When implementing, fetch a short-lived access token first:
|
||||
// const accessToken = await this.tokenProvider.getAccessToken(params.userSub);
|
||||
// Example (googleapis):
|
||||
// const auth = new google.auth.OAuth2();
|
||||
// auth.setCredentials({ access_token: accessToken });
|
||||
// const gmail = google.gmail({ version: 'v1', auth });
|
||||
// const res = await gmail.users.threads.get({
|
||||
// userId: 'me',
|
||||
// id: params.threadId,
|
||||
// format: 'full',
|
||||
// });
|
||||
// return parseThread(res.data);
|
||||
void params; // suppress unused-param warning; remove when real call is wired
|
||||
throw new Error(
|
||||
'GmailApiClient.getThreadDetail is not implemented — inject a GmailClient mock in tests and wire the real SDK call here for production.',
|
||||
);
|
||||
}
|
||||
}
|
||||
49
packages/gmail/src/index.ts
Normal file
49
packages/gmail/src/index.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* @sh-mcp/gmail public exports.
|
||||
*
|
||||
* The tools array is the primary export consumed by the server registry.
|
||||
* Each tool is constructed with an injected GmailClient so that the server
|
||||
* can supply the real GmailApiClient while tests supply a mock.
|
||||
*
|
||||
* Usage in a server:
|
||||
*
|
||||
* import { makeGmailTools } from '@sh-mcp/gmail';
|
||||
* import { GmailApiClient, LiveGoogleTokenProvider } from '@sh-mcp/gmail/client';
|
||||
*
|
||||
* const tools = makeGmailTools(new GmailApiClient(new LiveGoogleTokenProvider()));
|
||||
* registry.register(tools);
|
||||
*/
|
||||
|
||||
export { GmailApiClient } from './client.js';
|
||||
export type {
|
||||
GmailClient,
|
||||
GoogleTokenProvider,
|
||||
EmailMessage,
|
||||
EmailThread,
|
||||
SearchInboxParams,
|
||||
GetThreadDetailParams,
|
||||
} from './client.js';
|
||||
|
||||
export type {
|
||||
SearchInboxInput,
|
||||
SearchInboxOutput,
|
||||
GetEmailThreadDetailInput,
|
||||
GetEmailThreadDetailOutput,
|
||||
} from './tools.js';
|
||||
|
||||
export { makeSearchInboxTool, makeGetEmailThreadDetailTool } from './tools.js';
|
||||
|
||||
import type { GmailClient } from './client.js';
|
||||
import type { ToolDef } from '@sh-mcp/shared';
|
||||
import { makeSearchInboxTool, makeGetEmailThreadDetailTool } from './tools.js';
|
||||
|
||||
/**
|
||||
* Convenience factory: returns all gmail tools wired to the given client.
|
||||
* The server passes this array to its tool registry.
|
||||
*/
|
||||
export function makeGmailTools(client: GmailClient): ToolDef<unknown, unknown>[] {
|
||||
return [
|
||||
makeSearchInboxTool(client) as ToolDef<unknown, unknown>,
|
||||
makeGetEmailThreadDetailTool(client) as ToolDef<unknown, unknown>,
|
||||
];
|
||||
}
|
||||
187
packages/gmail/src/tools.ts
Normal file
187
packages/gmail/src/tools.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
/**
|
||||
* Gmail MCP tool definitions.
|
||||
*
|
||||
* Tools:
|
||||
* - search_inbox (scope: gmail:self, tier: ops)
|
||||
* - get_email_thread_detail (scope: gmail:self, tier: ops)
|
||||
*
|
||||
* Both tools act AS the signed-in user. The injected GmailClient is the
|
||||
* only outbound surface; no direct Google SDK import here.
|
||||
*
|
||||
* Per §2.5 of the design, these are ops-tier tools carrying private data
|
||||
* (risk: medium). redact() is called on message bodies and subjects to
|
||||
* strip any accidentally included bank/routing/card/SSN values before the
|
||||
* result is returned — even though this is an ops tool, emails can contain
|
||||
* finance data in transit.
|
||||
*/
|
||||
|
||||
import { type AuthContext, defineTool, requireScope, redact } from '@sh-mcp/shared';
|
||||
import type { GmailClient } from './client.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// search_inbox
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SearchInboxInput {
|
||||
/** Gmail query string. Examples: "from:vendor@example.com", "subject:invoice is:unread" */
|
||||
query: string;
|
||||
/**
|
||||
* Maximum number of messages to return. Capped server-side at 50.
|
||||
* @default 10
|
||||
*/
|
||||
maxResults?: number;
|
||||
}
|
||||
|
||||
export interface SearchInboxOutput {
|
||||
messages: Array<{
|
||||
id: string;
|
||||
threadId: string;
|
||||
subject: string;
|
||||
from: string;
|
||||
to: string;
|
||||
date: string;
|
||||
snippet: string;
|
||||
}>;
|
||||
totalReturned: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory: returns a search_inbox ToolDef with the supplied GmailClient
|
||||
* injected as its data source. The server calls this once at startup and
|
||||
* registers the resulting ToolDef.
|
||||
*/
|
||||
export function makeSearchInboxTool(client: GmailClient) {
|
||||
return defineTool<SearchInboxInput, SearchInboxOutput>({
|
||||
name: 'search_inbox',
|
||||
description:
|
||||
'Search the signed-in user\'s Gmail inbox using a Gmail query string. ' +
|
||||
'Returns matching message metadata and snippets. ' +
|
||||
'Acts as the authenticated user — never reads another mailbox.',
|
||||
tier: 'ops',
|
||||
requiredScope: 'gmail:self',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['query'],
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Gmail search query, e.g. "from:vendor@example.com subject:invoice is:unread".',
|
||||
minLength: 1,
|
||||
maxLength: 500,
|
||||
},
|
||||
maxResults: {
|
||||
type: 'integer',
|
||||
description: 'Maximum number of messages to return (1–50). Defaults to 10.',
|
||||
minimum: 1,
|
||||
maximum: 50,
|
||||
default: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: async (input: SearchInboxInput, ctx: AuthContext): Promise<SearchInboxOutput> => {
|
||||
// Server-side scope check — authoritative; UI tool-hiding is a convenience only.
|
||||
requireScope(ctx, 'gmail:self');
|
||||
|
||||
const maxResults = Math.min(input.maxResults ?? 10, 50);
|
||||
|
||||
const messages = await client.searchInbox({
|
||||
query: input.query,
|
||||
maxResults,
|
||||
userSub: ctx.sub,
|
||||
});
|
||||
|
||||
// Redact any sensitive values that may appear in email snippets/subjects.
|
||||
// This is an ops-tier tool, but emails can contain finance data in transit.
|
||||
const redacted = messages.map((m) => ({
|
||||
id: m.id,
|
||||
threadId: m.threadId,
|
||||
subject: redact(m.subject),
|
||||
from: m.from,
|
||||
to: m.to,
|
||||
date: m.date,
|
||||
snippet: redact(m.snippet),
|
||||
}));
|
||||
|
||||
return {
|
||||
messages: redacted,
|
||||
totalReturned: redacted.length,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// get_email_thread_detail
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface GetEmailThreadDetailInput {
|
||||
/** Gmail thread ID, as returned by search_inbox. */
|
||||
threadId: string;
|
||||
}
|
||||
|
||||
export interface GetEmailThreadDetailOutput {
|
||||
threadId: string;
|
||||
subject: string;
|
||||
messages: Array<{
|
||||
id: string;
|
||||
from: string;
|
||||
to: string;
|
||||
date: string;
|
||||
body: string;
|
||||
}>;
|
||||
messageCount: number;
|
||||
}
|
||||
|
||||
export function makeGetEmailThreadDetailTool(client: GmailClient) {
|
||||
return defineTool<GetEmailThreadDetailInput, GetEmailThreadDetailOutput>({
|
||||
name: 'get_email_thread_detail',
|
||||
description:
|
||||
'Retrieve the full message bodies of a Gmail thread by thread ID. ' +
|
||||
'Acts as the authenticated user — never reads another mailbox.',
|
||||
tier: 'ops',
|
||||
requiredScope: 'gmail:self',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['threadId'],
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
threadId: {
|
||||
type: 'string',
|
||||
description: 'Gmail thread ID, as returned by search_inbox.',
|
||||
minLength: 1,
|
||||
maxLength: 64,
|
||||
pattern: '^[A-Za-z0-9_-]+$',
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: async (
|
||||
input: GetEmailThreadDetailInput,
|
||||
ctx: AuthContext,
|
||||
): Promise<GetEmailThreadDetailOutput> => {
|
||||
requireScope(ctx, 'gmail:self');
|
||||
|
||||
const thread = await client.getThreadDetail({
|
||||
threadId: input.threadId,
|
||||
userSub: ctx.sub,
|
||||
});
|
||||
|
||||
// Redact sensitive fields in every message body and subject.
|
||||
const redactedMessages = thread.messages.map((m) => ({
|
||||
id: m.id,
|
||||
from: m.from,
|
||||
to: m.to,
|
||||
date: m.date,
|
||||
body: redact(m.body),
|
||||
}));
|
||||
|
||||
return {
|
||||
threadId: thread.threadId,
|
||||
subject: redact(thread.subject),
|
||||
messages: redactedMessages,
|
||||
messageCount: redactedMessages.length,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
313
packages/gmail/test/gmail.test.ts
Normal file
313
packages/gmail/test/gmail.test.ts
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
/**
|
||||
* Unit tests for @sh-mcp/gmail.
|
||||
*
|
||||
* The GmailClient is fully mocked — no real network calls, no Google SDK
|
||||
* imported at test time. A mock AuthContext is passed to each handler.
|
||||
*
|
||||
* Coverage:
|
||||
* - Happy path: search returns results / thread returns messages
|
||||
* - Empty result: client returns []
|
||||
* - Scope error: handler called with wrong scope throws ScopeError
|
||||
* - Client error: underlying client rejects with a transient error
|
||||
* - Throttle / retry: client rejects with a 429-style error
|
||||
* - Redaction: sensitive values in snippets/bodies are masked
|
||||
* - maxResults cap: enforced at 50 regardless of caller input
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { GmailClient, EmailMessage, EmailThread } from '../src/client.js';
|
||||
import { makeSearchInboxTool, makeGetEmailThreadDetailTool } from '../src/tools.js';
|
||||
import type { AuthContext } from '@sh-mcp/shared';
|
||||
import { ScopeError } from '@sh-mcp/shared';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function mockAuthContext(overrides?: Partial<AuthContext>): AuthContext {
|
||||
return {
|
||||
sub: 'lauren@seahavenind.com',
|
||||
scopes: ['gmail:self', 'ops:read'],
|
||||
aud: 'sh-mcp-ops',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mockEmailMessage(overrides?: Partial<EmailMessage>): EmailMessage {
|
||||
return {
|
||||
id: 'msg_001',
|
||||
threadId: 'thread_001',
|
||||
subject: 'Invoice from ACME Corp',
|
||||
from: 'billing@acme.com',
|
||||
to: 'lauren@seahavenind.com',
|
||||
date: '2026-06-01T10:00:00Z',
|
||||
snippet: 'Please find the invoice attached.',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mockEmailThread(overrides?: Partial<EmailThread>): EmailThread {
|
||||
return {
|
||||
threadId: 'thread_001',
|
||||
subject: 'Invoice from ACME Corp',
|
||||
messages: [
|
||||
{
|
||||
id: 'msg_001',
|
||||
from: 'billing@acme.com',
|
||||
to: 'lauren@seahavenind.com',
|
||||
date: '2026-06-01T10:00:00Z',
|
||||
body: 'Please find the invoice attached. Total due: $1,500.',
|
||||
},
|
||||
{
|
||||
id: 'msg_002',
|
||||
from: 'lauren@seahavenind.com',
|
||||
to: 'billing@acme.com',
|
||||
date: '2026-06-02T09:00:00Z',
|
||||
body: 'Thank you, received.',
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeMockClient(): GmailClient {
|
||||
return {
|
||||
searchInbox: vi.fn(),
|
||||
getThreadDetail: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// search_inbox
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('search_inbox', () => {
|
||||
let client: GmailClient;
|
||||
|
||||
beforeEach(() => {
|
||||
client = makeMockClient();
|
||||
});
|
||||
|
||||
it('happy path — returns matched messages', async () => {
|
||||
const messages = [mockEmailMessage(), mockEmailMessage({ id: 'msg_002', threadId: 'thread_002' })];
|
||||
vi.mocked(client.searchInbox).mockResolvedValueOnce(messages);
|
||||
|
||||
const tool = makeSearchInboxTool(client);
|
||||
const result = await tool.handler(
|
||||
{ query: 'from:billing@acme.com subject:invoice' },
|
||||
mockAuthContext(),
|
||||
);
|
||||
|
||||
expect(result.totalReturned).toBe(2);
|
||||
expect(result.messages).toHaveLength(2);
|
||||
expect(result.messages[0].id).toBe('msg_001');
|
||||
expect(result.messages[0].subject).toBe('Invoice from ACME Corp');
|
||||
expect(vi.mocked(client.searchInbox)).toHaveBeenCalledWith({
|
||||
query: 'from:billing@acme.com subject:invoice',
|
||||
maxResults: 10,
|
||||
userSub: 'lauren@seahavenind.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('respects custom maxResults', async () => {
|
||||
vi.mocked(client.searchInbox).mockResolvedValueOnce([]);
|
||||
const tool = makeSearchInboxTool(client);
|
||||
await tool.handler({ query: 'is:unread', maxResults: 25 }, mockAuthContext());
|
||||
|
||||
expect(vi.mocked(client.searchInbox)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ maxResults: 25 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('caps maxResults at 50 even when caller requests more', async () => {
|
||||
vi.mocked(client.searchInbox).mockResolvedValueOnce([]);
|
||||
const tool = makeSearchInboxTool(client);
|
||||
await tool.handler({ query: 'is:unread', maxResults: 999 }, mockAuthContext());
|
||||
|
||||
expect(vi.mocked(client.searchInbox)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ maxResults: 50 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('empty result — returns empty array', async () => {
|
||||
vi.mocked(client.searchInbox).mockResolvedValueOnce([]);
|
||||
const tool = makeSearchInboxTool(client);
|
||||
const result = await tool.handler({ query: 'subject:nonexistent' }, mockAuthContext());
|
||||
|
||||
expect(result.messages).toEqual([]);
|
||||
expect(result.totalReturned).toBe(0);
|
||||
});
|
||||
|
||||
it('throws ScopeError when gmail:self scope is missing', async () => {
|
||||
const tool = makeSearchInboxTool(client);
|
||||
const ctx = mockAuthContext({ scopes: ['ops:read'] });
|
||||
|
||||
await expect(tool.handler({ query: 'test' }, ctx)).rejects.toThrow(ScopeError);
|
||||
expect(vi.mocked(client.searchInbox)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagates client error', async () => {
|
||||
vi.mocked(client.searchInbox).mockRejectedValueOnce(new Error('Gmail API unavailable'));
|
||||
const tool = makeSearchInboxTool(client);
|
||||
|
||||
await expect(
|
||||
tool.handler({ query: 'test' }, mockAuthContext()),
|
||||
).rejects.toThrow('Gmail API unavailable');
|
||||
});
|
||||
|
||||
it('propagates throttle / 429-style error', async () => {
|
||||
const throttleError = Object.assign(new Error('Rate limit exceeded'), { code: 429 });
|
||||
vi.mocked(client.searchInbox).mockRejectedValueOnce(throttleError);
|
||||
const tool = makeSearchInboxTool(client);
|
||||
|
||||
const err = await tool.handler({ query: 'test' }, mockAuthContext()).catch((e: unknown) => e);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect((err as NodeJS.ErrnoException & { code?: number }).code).toBe(429);
|
||||
});
|
||||
|
||||
it('redacts sensitive values in snippets and subjects', async () => {
|
||||
const sensitiveMessage = mockEmailMessage({
|
||||
subject: 'Wire details: routing 021000021 account 1234567890',
|
||||
snippet: 'SSN 123-45-6789 card 4111111111111111',
|
||||
});
|
||||
vi.mocked(client.searchInbox).mockResolvedValueOnce([sensitiveMessage]);
|
||||
|
||||
const tool = makeSearchInboxTool(client);
|
||||
const result = await tool.handler({ query: 'test' }, mockAuthContext());
|
||||
|
||||
// Sensitive patterns must not appear in the output.
|
||||
expect(result.messages[0].subject).not.toContain('021000021');
|
||||
expect(result.messages[0].snippet).not.toContain('123-45-6789');
|
||||
expect(result.messages[0].snippet).not.toContain('4111111111111111');
|
||||
});
|
||||
|
||||
it('passes the calling user sub to the client (never reads another mailbox)', async () => {
|
||||
vi.mocked(client.searchInbox).mockResolvedValueOnce([]);
|
||||
const tool = makeSearchInboxTool(client);
|
||||
const ctx = mockAuthContext({ sub: 'adam@seahavenind.com' });
|
||||
await tool.handler({ query: 'test' }, ctx);
|
||||
|
||||
expect(vi.mocked(client.searchInbox)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userSub: 'adam@seahavenind.com' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// get_email_thread_detail
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('get_email_thread_detail', () => {
|
||||
let client: GmailClient;
|
||||
|
||||
beforeEach(() => {
|
||||
client = makeMockClient();
|
||||
});
|
||||
|
||||
it('happy path — returns full thread', async () => {
|
||||
const thread = mockEmailThread();
|
||||
vi.mocked(client.getThreadDetail).mockResolvedValueOnce(thread);
|
||||
|
||||
const tool = makeGetEmailThreadDetailTool(client);
|
||||
const result = await tool.handler({ threadId: 'thread_001' }, mockAuthContext());
|
||||
|
||||
expect(result.threadId).toBe('thread_001');
|
||||
expect(result.subject).toBe('Invoice from ACME Corp');
|
||||
expect(result.messageCount).toBe(2);
|
||||
expect(result.messages).toHaveLength(2);
|
||||
expect(result.messages[0].body).toContain('Total due');
|
||||
expect(vi.mocked(client.getThreadDetail)).toHaveBeenCalledWith({
|
||||
threadId: 'thread_001',
|
||||
userSub: 'lauren@seahavenind.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('empty thread — returns zero messages', async () => {
|
||||
vi.mocked(client.getThreadDetail).mockResolvedValueOnce(
|
||||
mockEmailThread({ messages: [] }),
|
||||
);
|
||||
const tool = makeGetEmailThreadDetailTool(client);
|
||||
const result = await tool.handler({ threadId: 'thread_empty' }, mockAuthContext());
|
||||
|
||||
expect(result.messages).toEqual([]);
|
||||
expect(result.messageCount).toBe(0);
|
||||
});
|
||||
|
||||
it('throws ScopeError when gmail:self scope is missing', async () => {
|
||||
const tool = makeGetEmailThreadDetailTool(client);
|
||||
const ctx = mockAuthContext({ scopes: ['ops:read', 'ops:tasks'] });
|
||||
|
||||
await expect(tool.handler({ threadId: 'thread_001' }, ctx)).rejects.toThrow(ScopeError);
|
||||
expect(vi.mocked(client.getThreadDetail)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagates client error', async () => {
|
||||
vi.mocked(client.getThreadDetail).mockRejectedValueOnce(new Error('Thread not found'));
|
||||
const tool = makeGetEmailThreadDetailTool(client);
|
||||
|
||||
await expect(
|
||||
tool.handler({ threadId: 'thread_001' }, mockAuthContext()),
|
||||
).rejects.toThrow('Thread not found');
|
||||
});
|
||||
|
||||
it('propagates throttle / 429-style error', async () => {
|
||||
const throttleError = Object.assign(new Error('Quota exceeded'), { code: 429, retryAfter: 5 });
|
||||
vi.mocked(client.getThreadDetail).mockRejectedValueOnce(throttleError);
|
||||
const tool = makeGetEmailThreadDetailTool(client);
|
||||
|
||||
const err = await tool
|
||||
.handler({ threadId: 'thread_001' }, mockAuthContext())
|
||||
.catch((e: unknown) => e);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect((err as NodeJS.ErrnoException & { code?: number }).code).toBe(429);
|
||||
});
|
||||
|
||||
it('redacts sensitive values in message bodies and subject', async () => {
|
||||
const sensitiveThread = mockEmailThread({
|
||||
subject: 'ACH routing 021000021',
|
||||
messages: [
|
||||
{
|
||||
id: 'msg_s1',
|
||||
from: 'billing@acme.com',
|
||||
to: 'lauren@seahavenind.com',
|
||||
date: '2026-06-01T10:00:00Z',
|
||||
body: 'Account number 9876543210 routing 021000021 SSN 987-65-4321',
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(client.getThreadDetail).mockResolvedValueOnce(sensitiveThread);
|
||||
|
||||
const tool = makeGetEmailThreadDetailTool(client);
|
||||
const result = await tool.handler({ threadId: 'thread_sensitive' }, mockAuthContext());
|
||||
|
||||
expect(result.subject).not.toContain('021000021');
|
||||
expect(result.messages[0].body).not.toContain('9876543210');
|
||||
expect(result.messages[0].body).not.toContain('021000021');
|
||||
expect(result.messages[0].body).not.toContain('987-65-4321');
|
||||
});
|
||||
|
||||
it('passes the calling user sub to the client (never reads another mailbox)', async () => {
|
||||
vi.mocked(client.getThreadDetail).mockResolvedValueOnce(mockEmailThread());
|
||||
const tool = makeGetEmailThreadDetailTool(client);
|
||||
const ctx = mockAuthContext({ sub: 'adam@seahavenind.com' });
|
||||
await tool.handler({ threadId: 'thread_001' }, ctx);
|
||||
|
||||
expect(vi.mocked(client.getThreadDetail)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userSub: 'adam@seahavenind.com' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('tool metadata is correct', () => {
|
||||
const tool = makeGetEmailThreadDetailTool(client);
|
||||
expect(tool.name).toBe('get_email_thread_detail');
|
||||
expect(tool.tier).toBe('ops');
|
||||
expect(tool.requiredScope).toBe('gmail:self');
|
||||
});
|
||||
|
||||
it('search_inbox tool metadata is correct', () => {
|
||||
const tool = makeSearchInboxTool(client);
|
||||
expect(tool.name).toBe('search_inbox');
|
||||
expect(tool.tier).toBe('ops');
|
||||
expect(tool.requiredScope).toBe('gmail:self');
|
||||
});
|
||||
});
|
||||
9
packages/gmail/tsconfig.json
Normal file
9
packages/gmail/tsconfig.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declarationDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
26
packages/google-maps/package.json
Normal file
26
packages/google-maps/package.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"name": "@sh-mcp/google-maps",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Sea Haven MCP — Google Maps (Places) tools",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc --project tsconfig.json",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sh-mcp/shared": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.5.0",
|
||||
"vitest": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
}
|
||||
}
|
||||
130
packages/google-maps/src/client.ts
Normal file
130
packages/google-maps/src/client.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
/**
|
||||
* Google Maps client interface.
|
||||
*
|
||||
* The implementation below stubs the real Google Places API (New) searchText call.
|
||||
* It is injected at construction time so tests can pass a mock without any network
|
||||
* traffic or import-time side-effects.
|
||||
*
|
||||
* TODO (DEFERRED auth layer): the apiKey will be injected via Secrets Manager at
|
||||
* Lambda cold-start; the calling server reads it from the injected config and passes
|
||||
* it here. No API key must ever be hard-coded or logged.
|
||||
*/
|
||||
|
||||
export interface PlaceResult {
|
||||
/** Display name of the place. */
|
||||
displayName: string;
|
||||
/** Human-readable formatted address. */
|
||||
formattedAddress: string;
|
||||
/** Primary phone number, if available. */
|
||||
nationalPhoneNumber?: string;
|
||||
/** Types/categories assigned by Google. */
|
||||
types: string[];
|
||||
/** 0–5 star rating, if available. */
|
||||
rating?: number;
|
||||
/** Google Maps URI for the place. */
|
||||
googleMapsUri?: string;
|
||||
}
|
||||
|
||||
export interface SearchTextParams {
|
||||
textQuery: string;
|
||||
/** Maximum number of results to return (1–20). */
|
||||
maxResultCount?: number;
|
||||
/** Radius in metres for a location bias (requires locationBias set). */
|
||||
locationBiasRadiusMeters?: number;
|
||||
/** Latitude for location bias centre. */
|
||||
locationBiasLat?: number;
|
||||
/** Longitude for location bias centre. */
|
||||
locationBiasLng?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The injectable client interface every tool receives.
|
||||
* Tests provide a mock; the real Lambda wires up the concrete implementation.
|
||||
*/
|
||||
export interface GoogleMapsClient {
|
||||
searchText(params: SearchTextParams): Promise<PlaceResult[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Concrete implementation that calls the Google Places API (New).
|
||||
*
|
||||
* IMPORTANT: do NOT pass `includedType: "contractor"` — the Places API (New)
|
||||
* returns a 400 for that value. Use only free-text queries.
|
||||
*/
|
||||
export class GooglePlacesClient implements GoogleMapsClient {
|
||||
private readonly apiKey: string;
|
||||
private readonly baseUrl = 'https://places.googleapis.com/v1/places:searchText';
|
||||
|
||||
constructor(apiKey: string) {
|
||||
if (!apiKey) {
|
||||
throw new Error('GooglePlacesClient: apiKey is required');
|
||||
}
|
||||
this.apiKey = apiKey;
|
||||
}
|
||||
|
||||
async searchText(params: SearchTextParams): Promise<PlaceResult[]> {
|
||||
const body: Record<string, unknown> = {
|
||||
textQuery: params.textQuery,
|
||||
maxResultCount: params.maxResultCount ?? 10,
|
||||
};
|
||||
|
||||
if (
|
||||
params.locationBiasLat !== undefined &&
|
||||
params.locationBiasLng !== undefined &&
|
||||
params.locationBiasRadiusMeters !== undefined
|
||||
) {
|
||||
body['locationBias'] = {
|
||||
circle: {
|
||||
center: {
|
||||
latitude: params.locationBiasLat,
|
||||
longitude: params.locationBiasLng,
|
||||
},
|
||||
radius: params.locationBiasRadiusMeters,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(this.baseUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// Field mask limits billing to only the fields we actually use.
|
||||
'X-Goog-FieldMask':
|
||||
'places.displayName,places.formattedAddress,places.nationalPhoneNumber,places.types,places.rating,places.googleMapsUri',
|
||||
'X-Goog-Api-Key': this.apiKey,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (response.status === 429) {
|
||||
const err = new Error('Google Places API rate limit exceeded');
|
||||
(err as NodeJS.ErrnoException).code = 'RATE_LIMITED';
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => response.statusText);
|
||||
throw new Error(`Google Places API error ${response.status}: ${text}`);
|
||||
}
|
||||
|
||||
const json = (await response.json()) as {
|
||||
places?: Array<{
|
||||
displayName?: { text?: string };
|
||||
formattedAddress?: string;
|
||||
nationalPhoneNumber?: string;
|
||||
types?: string[];
|
||||
rating?: number;
|
||||
googleMapsUri?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
return (json.places ?? []).map((p) => ({
|
||||
displayName: p.displayName?.text ?? 'Unknown',
|
||||
formattedAddress: p.formattedAddress ?? '',
|
||||
nationalPhoneNumber: p.nationalPhoneNumber,
|
||||
types: p.types ?? [],
|
||||
rating: p.rating,
|
||||
googleMapsUri: p.googleMapsUri,
|
||||
}));
|
||||
}
|
||||
}
|
||||
40
packages/google-maps/src/index.ts
Normal file
40
packages/google-maps/src/index.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
/**
|
||||
* @sh-mcp/google-maps — public entry point.
|
||||
*
|
||||
* Exports the tools array and the client interface so callers can inject
|
||||
* a concrete GooglePlacesClient (or a mock in tests).
|
||||
*
|
||||
* Usage in a server:
|
||||
*
|
||||
* import { tools } from '@sh-mcp/google-maps';
|
||||
* import { GooglePlacesClient } from '@sh-mcp/google-maps/client';
|
||||
*
|
||||
* const mapsClient = new GooglePlacesClient(process.env.GOOGLE_MAPS_API_KEY!);
|
||||
* // tools is pre-wired with the concrete client
|
||||
*
|
||||
* The tools array is also exported for dynamic wiring:
|
||||
*
|
||||
* import { makeTools } from '@sh-mcp/google-maps';
|
||||
* const tools = makeTools(new GooglePlacesClient(apiKey));
|
||||
*/
|
||||
|
||||
export { GooglePlacesClient } from './client.js';
|
||||
export type { GoogleMapsClient, PlaceResult, SearchTextParams } from './client.js';
|
||||
export type { SearchNearbyVendorsInput, SearchNearbyVendorsOutput, VendorListing } from './tools.js';
|
||||
export { makeSearchNearbyVendors } from './tools.js';
|
||||
|
||||
import type { ToolDef } from '@sh-mcp/shared';
|
||||
import type { GoogleMapsClient } from './client.js';
|
||||
import { makeSearchNearbyVendors } from './tools.js';
|
||||
|
||||
/**
|
||||
* Build the full tools array wired to an injected client.
|
||||
* Servers call this at startup with their concrete GooglePlacesClient.
|
||||
*/
|
||||
export function makeTools(
|
||||
client: GoogleMapsClient,
|
||||
): ToolDef<unknown, unknown>[] {
|
||||
return [
|
||||
makeSearchNearbyVendors(client) as ToolDef<unknown, unknown>,
|
||||
];
|
||||
}
|
||||
144
packages/google-maps/src/tools.ts
Normal file
144
packages/google-maps/src/tools.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
/**
|
||||
* Google Maps MCP tools.
|
||||
*
|
||||
* Trust tier: ops
|
||||
* Required scope: ops:read
|
||||
*
|
||||
* All tools in this package call the injected GoogleMapsClient — no direct
|
||||
* network calls here. Tests substitute a mock client.
|
||||
*/
|
||||
|
||||
import { defineTool, requireScope } from '@sh-mcp/shared';
|
||||
import type { AuthContext } from '@sh-mcp/shared';
|
||||
import type { GoogleMapsClient, PlaceResult } from './client.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input / output types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SearchNearbyVendorsInput {
|
||||
/** Free-text query, e.g. "electrician" or "plumbing supply near downtown". */
|
||||
query: string;
|
||||
/**
|
||||
* Optional latitude of the search centre for a location bias.
|
||||
* Must be provided together with lng and radius_meters.
|
||||
*/
|
||||
lat?: number;
|
||||
/**
|
||||
* Optional longitude of the search centre for a location bias.
|
||||
* Must be provided together with lat and radius_meters.
|
||||
*/
|
||||
lng?: number;
|
||||
/**
|
||||
* Bias radius in metres (max 50 000). Ignored unless lat + lng are also set.
|
||||
*/
|
||||
radius_meters?: number;
|
||||
/** Maximum number of results to return (1–20, default 10). */
|
||||
max_results?: number;
|
||||
}
|
||||
|
||||
export interface VendorListing {
|
||||
name: string;
|
||||
address: string;
|
||||
phone?: string;
|
||||
categories: string[];
|
||||
rating?: number;
|
||||
maps_url?: string;
|
||||
/** Always present — callers must surface this to users. */
|
||||
disclaimer: string;
|
||||
}
|
||||
|
||||
export interface SearchNearbyVendorsOutput {
|
||||
results: VendorListing[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool factory — accepts an injected client so tests can mock it
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function makeSearchNearbyVendors(client: GoogleMapsClient) {
|
||||
return defineTool<SearchNearbyVendorsInput, SearchNearbyVendorsOutput>({
|
||||
name: 'search_nearby_vendors',
|
||||
description:
|
||||
'Search for nearby vendors or service providers using the Google Places API. ' +
|
||||
'Returns unvetted listings from Google Maps — always label results as unvetted to the user.',
|
||||
tier: 'ops',
|
||||
requiredScope: 'ops:read',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['query'],
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Free-text search query (e.g. "electrician", "plumbing supply shop"). ' +
|
||||
'Do NOT include the word "contractor" as a type qualifier — use a descriptive phrase instead.',
|
||||
minLength: 1,
|
||||
maxLength: 200,
|
||||
},
|
||||
lat: {
|
||||
type: 'number',
|
||||
description: 'Latitude of the search centre for a location bias (-90 to 90).',
|
||||
minimum: -90,
|
||||
maximum: 90,
|
||||
},
|
||||
lng: {
|
||||
type: 'number',
|
||||
description: 'Longitude of the search centre for a location bias (-180 to 180).',
|
||||
minimum: -180,
|
||||
maximum: 180,
|
||||
},
|
||||
radius_meters: {
|
||||
type: 'number',
|
||||
description: 'Location-bias radius in metres (1–50000). Requires lat + lng.',
|
||||
minimum: 1,
|
||||
maximum: 50000,
|
||||
},
|
||||
max_results: {
|
||||
type: 'integer',
|
||||
description: 'Maximum number of results to return (1–20, default 10).',
|
||||
minimum: 1,
|
||||
maximum: 20,
|
||||
default: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
async handler(
|
||||
input: SearchNearbyVendorsInput,
|
||||
ctx: AuthContext,
|
||||
): Promise<SearchNearbyVendorsOutput> {
|
||||
// TODO (DEFERRED auth layer): requireScope currently validates the scope
|
||||
// claim present in ctx.scopes. Real JWT signature verification, audience
|
||||
// binding (ctx.aud === 'sh-mcp-ops'), and issuer checks are implemented in
|
||||
// the DEFERRED auth middleware layer — not here.
|
||||
requireScope(ctx, 'ops:read');
|
||||
|
||||
const places: PlaceResult[] = await client.searchText({
|
||||
textQuery: input.query,
|
||||
maxResultCount: input.max_results ?? 10,
|
||||
locationBiasLat: input.lat,
|
||||
locationBiasLng: input.lng,
|
||||
locationBiasRadiusMeters: input.radius_meters,
|
||||
});
|
||||
|
||||
const disclaimer =
|
||||
'UNVETTED: These results are sourced directly from Google Maps and have not ' +
|
||||
'been verified by Sea Haven Industries. Always confirm vendor credentials, ' +
|
||||
'licensing, and insurance before engaging any vendor.';
|
||||
|
||||
const results: VendorListing[] = places.map((p) => ({
|
||||
name: p.displayName,
|
||||
address: p.formattedAddress,
|
||||
phone: p.nationalPhoneNumber,
|
||||
categories: p.types,
|
||||
rating: p.rating,
|
||||
maps_url: p.googleMapsUri,
|
||||
disclaimer,
|
||||
}));
|
||||
|
||||
return { results, total: results.length };
|
||||
},
|
||||
});
|
||||
}
|
||||
264
packages/google-maps/test/google-maps.test.ts
Normal file
264
packages/google-maps/test/google-maps.test.ts
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
/**
|
||||
* Unit tests for @sh-mcp/google-maps
|
||||
*
|
||||
* All tests use a mock GoogleMapsClient — no real network calls are made.
|
||||
* The mock AuthContext satisfies the @sh-mcp/shared contract.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import type { AuthContext } from '@sh-mcp/shared';
|
||||
import type { GoogleMapsClient, PlaceResult } from '../src/client.js';
|
||||
import { makeSearchNearbyVendors } from '../src/tools.js';
|
||||
import type { SearchNearbyVendorsInput } from '../src/tools.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function mockAuthCtx(overrides?: Partial<AuthContext>): AuthContext {
|
||||
return {
|
||||
sub: 'test-user@seahavenind.com',
|
||||
scopes: ['ops:read'],
|
||||
aud: 'sh-mcp-ops',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const SAMPLE_PLACE: PlaceResult = {
|
||||
displayName: 'Acme Electric LLC',
|
||||
formattedAddress: '123 Main St, Ronkonkoma, NY 11779',
|
||||
nationalPhoneNumber: '(631) 555-0100',
|
||||
types: ['electrician', 'home_goods_store'],
|
||||
rating: 4.7,
|
||||
googleMapsUri: 'https://maps.google.com/?cid=12345',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock client factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeMockClient(
|
||||
impl: () => Promise<PlaceResult[]>,
|
||||
): GoogleMapsClient {
|
||||
return { searchText: vi.fn().mockImplementation(impl) };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('search_nearby_vendors', () => {
|
||||
describe('happy path — results returned', () => {
|
||||
it('returns shaped VendorListing objects with an unvetted disclaimer', async () => {
|
||||
const client = makeMockClient(async () => [SAMPLE_PLACE]);
|
||||
const tool = makeSearchNearbyVendors(client);
|
||||
|
||||
const input: SearchNearbyVendorsInput = { query: 'electrician near Ronkonkoma NY' };
|
||||
const result = await tool.handler(input, mockAuthCtx());
|
||||
|
||||
expect(result.total).toBe(1);
|
||||
const vendor = result.results[0];
|
||||
expect(vendor.name).toBe('Acme Electric LLC');
|
||||
expect(vendor.address).toBe('123 Main St, Ronkonkoma, NY 11779');
|
||||
expect(vendor.phone).toBe('(631) 555-0100');
|
||||
expect(vendor.categories).toContain('electrician');
|
||||
expect(vendor.rating).toBe(4.7);
|
||||
expect(vendor.maps_url).toBe('https://maps.google.com/?cid=12345');
|
||||
expect(vendor.disclaimer).toMatch(/UNVETTED/);
|
||||
});
|
||||
|
||||
it('passes through location bias params to the client', async () => {
|
||||
const client = makeMockClient(async () => [SAMPLE_PLACE]);
|
||||
const tool = makeSearchNearbyVendors(client);
|
||||
|
||||
const input: SearchNearbyVendorsInput = {
|
||||
query: 'plumber',
|
||||
lat: 40.8296,
|
||||
lng: -73.1040,
|
||||
radius_meters: 8000,
|
||||
max_results: 5,
|
||||
};
|
||||
await tool.handler(input, mockAuthCtx());
|
||||
|
||||
expect(client.searchText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
textQuery: 'plumber',
|
||||
locationBiasLat: 40.8296,
|
||||
locationBiasLng: -73.1040,
|
||||
locationBiasRadiusMeters: 8000,
|
||||
maxResultCount: 5,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults max_results to 10 when not supplied', async () => {
|
||||
const client = makeMockClient(async () => []);
|
||||
const tool = makeSearchNearbyVendors(client);
|
||||
|
||||
await tool.handler({ query: 'locksmith' }, mockAuthCtx());
|
||||
|
||||
expect(client.searchText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ maxResultCount: 10 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('handles optional fields absent (no phone, rating, maps_url)', async () => {
|
||||
const sparse: PlaceResult = {
|
||||
displayName: 'Sparse Vendor',
|
||||
formattedAddress: '456 Oak Ave',
|
||||
types: ['general_contractor'],
|
||||
};
|
||||
const client = makeMockClient(async () => [sparse]);
|
||||
const tool = makeSearchNearbyVendors(client);
|
||||
|
||||
const result = await tool.handler({ query: 'general contractor' }, mockAuthCtx());
|
||||
|
||||
const vendor = result.results[0];
|
||||
expect(vendor.phone).toBeUndefined();
|
||||
expect(vendor.rating).toBeUndefined();
|
||||
expect(vendor.maps_url).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty results', () => {
|
||||
it('returns an empty array and total 0 when the API finds nothing', async () => {
|
||||
const client = makeMockClient(async () => []);
|
||||
const tool = makeSearchNearbyVendors(client);
|
||||
|
||||
const result = await tool.handler(
|
||||
{ query: 'zxzxzx nonsense query' },
|
||||
mockAuthCtx(),
|
||||
);
|
||||
|
||||
expect(result.total).toBe(0);
|
||||
expect(result.results).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('propagates a generic API error to the caller', async () => {
|
||||
const client = makeMockClient(async () => {
|
||||
throw new Error('Google Places API error 500: Internal Server Error');
|
||||
});
|
||||
const tool = makeSearchNearbyVendors(client);
|
||||
|
||||
await expect(
|
||||
tool.handler({ query: 'electrician' }, mockAuthCtx()),
|
||||
).rejects.toThrow('Google Places API error 500');
|
||||
});
|
||||
|
||||
it('propagates a 400 Bad Request error to the caller', async () => {
|
||||
const client = makeMockClient(async () => {
|
||||
throw new Error('Google Places API error 400: INVALID_ARGUMENT');
|
||||
});
|
||||
const tool = makeSearchNearbyVendors(client);
|
||||
|
||||
await expect(
|
||||
tool.handler({ query: 'bad request' }, mockAuthCtx()),
|
||||
).rejects.toThrow('400');
|
||||
});
|
||||
});
|
||||
|
||||
describe('throttle / retry', () => {
|
||||
it('surfaces a RATE_LIMITED error when the client throws one', async () => {
|
||||
const rateLimitErr = Object.assign(
|
||||
new Error('Google Places API rate limit exceeded'),
|
||||
{ code: 'RATE_LIMITED' },
|
||||
);
|
||||
const client = makeMockClient(async () => { throw rateLimitErr; });
|
||||
const tool = makeSearchNearbyVendors(client);
|
||||
|
||||
await expect(
|
||||
tool.handler({ query: 'electrician' }, mockAuthCtx()),
|
||||
).rejects.toMatchObject({ code: 'RATE_LIMITED' });
|
||||
});
|
||||
|
||||
it('succeeds on a second attempt when the first throws RATE_LIMITED', async () => {
|
||||
let calls = 0;
|
||||
const client: GoogleMapsClient = {
|
||||
searchText: vi.fn().mockImplementation(async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
const err = Object.assign(
|
||||
new Error('Google Places API rate limit exceeded'),
|
||||
{ code: 'RATE_LIMITED' },
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
return [SAMPLE_PLACE];
|
||||
}),
|
||||
};
|
||||
|
||||
// The tool itself does not retry — retry belongs in the server/transport
|
||||
// layer. This test documents that the error is throw-able and re-tryable
|
||||
// by calling the handler twice directly.
|
||||
const tool = makeSearchNearbyVendors(client);
|
||||
const ctx = mockAuthCtx();
|
||||
|
||||
// First call fails
|
||||
await expect(tool.handler({ query: 'electrician' }, ctx)).rejects.toThrow();
|
||||
|
||||
// Second call succeeds
|
||||
const result = await tool.handler({ query: 'electrician' }, ctx);
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.results[0].name).toBe('Acme Electric LLC');
|
||||
});
|
||||
});
|
||||
|
||||
describe('scope enforcement', () => {
|
||||
it('throws ScopeError when the context lacks ops:read', async () => {
|
||||
const client = makeMockClient(async () => [SAMPLE_PLACE]);
|
||||
const tool = makeSearchNearbyVendors(client);
|
||||
|
||||
// Context with an empty scope list — no ops:read
|
||||
const ctx = mockAuthCtx({ scopes: [] });
|
||||
|
||||
await expect(
|
||||
tool.handler({ query: 'electrician' }, ctx),
|
||||
).rejects.toThrow();
|
||||
|
||||
// The client must never have been called if scope fails
|
||||
expect(client.searchText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws ScopeError when only a finance scope is present', async () => {
|
||||
const client = makeMockClient(async () => [SAMPLE_PLACE]);
|
||||
const tool = makeSearchNearbyVendors(client);
|
||||
|
||||
const ctx = mockAuthCtx({ scopes: ['finance:read'] });
|
||||
|
||||
await expect(
|
||||
tool.handler({ query: 'electrician' }, ctx),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(client.searchText).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tool metadata', () => {
|
||||
it('has the correct name, tier, and requiredScope', () => {
|
||||
const client = makeMockClient(async () => []);
|
||||
const tool = makeSearchNearbyVendors(client);
|
||||
|
||||
expect(tool.name).toBe('search_nearby_vendors');
|
||||
expect(tool.tier).toBe('ops');
|
||||
expect(tool.requiredScope).toBe('ops:read');
|
||||
});
|
||||
|
||||
it('has a valid JSON Schema with query as a required property', () => {
|
||||
const client = makeMockClient(async () => []);
|
||||
const tool = makeSearchNearbyVendors(client);
|
||||
const schema = tool.inputSchema as {
|
||||
required: string[];
|
||||
properties: Record<string, unknown>;
|
||||
};
|
||||
|
||||
expect(schema.required).toContain('query');
|
||||
expect(schema.properties).toHaveProperty('query');
|
||||
expect(schema.properties).toHaveProperty('lat');
|
||||
expect(schema.properties).toHaveProperty('lng');
|
||||
expect(schema.properties).toHaveProperty('radius_meters');
|
||||
expect(schema.properties).toHaveProperty('max_results');
|
||||
});
|
||||
});
|
||||
});
|
||||
9
packages/google-maps/tsconfig.json
Normal file
9
packages/google-maps/tsconfig.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"declarationDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
28
packages/internal-data/package.json
Normal file
28
packages/internal-data/package.json
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"name": "@sh-mcp/internal-data",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Sea Haven internal-data tools: work-order, purchase-order, and site lookups backed by DynamoDB",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc --project tsconfig.json",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sh-mcp/shared": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@vitest/coverage-v8": "^2.0.0",
|
||||
"typescript": "^5.5.0",
|
||||
"vitest": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
}
|
||||
}
|
||||
176
packages/internal-data/src/client.ts
Normal file
176
packages/internal-data/src/client.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
/**
|
||||
* DynamoDB client interface + thin implementation.
|
||||
*
|
||||
* The interface is what every tool handler receives — callers (including tests)
|
||||
* inject any object that satisfies it. The real implementation wraps the AWS SDK
|
||||
* DynamoDB DocumentClient, but the SDK is only instantiated when
|
||||
* RealDynamoClient.create() is explicitly called; nothing happens at import time
|
||||
* and no AWS calls are made unless you call a method.
|
||||
*
|
||||
* Tables used by this package:
|
||||
* WorkOrders – work-order records, keyed on `workOrderId` (PK)
|
||||
* purchase-orders – purchase-order records, keyed on `purchaseOrderId` (PK)
|
||||
* SiteAssignments – site records, keyed on `siteId` (PK)
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DynamoDB record shapes returned from each table
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface WorkOrderRecord {
|
||||
workOrderId: string;
|
||||
title: string;
|
||||
status: string;
|
||||
siteId?: string;
|
||||
assignedTo?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
description?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface PurchaseOrderRecord {
|
||||
purchaseOrderId: string;
|
||||
vendor: string;
|
||||
status: string;
|
||||
totalAmount?: number;
|
||||
currency?: string;
|
||||
issuedAt: string;
|
||||
updatedAt: string;
|
||||
lineItems?: Array<{ description: string; quantity: number; unitPrice: number }>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SiteRecord {
|
||||
siteId: string;
|
||||
name: string;
|
||||
address?: string;
|
||||
region?: string;
|
||||
status: string;
|
||||
assignedTechnicians?: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Client interface — inject this everywhere; never import the AWS SDK directly
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface InternalDataClient {
|
||||
getWorkOrder(workOrderId: string): Promise<WorkOrderRecord | null>;
|
||||
getPurchaseOrder(purchaseOrderId: string): Promise<PurchaseOrderRecord | null>;
|
||||
getSite(siteId: string): Promise<SiteRecord | null>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Real (AWS SDK-backed) implementation
|
||||
//
|
||||
// The AWS SDK import lives here — behind this class — so that:
|
||||
// a) Nothing happens at module load time (no credential resolution, no env reads).
|
||||
// b) Tests never reach this code; they inject a mock that satisfies the interface.
|
||||
//
|
||||
// TODO (DEFERRED auth layer): When the Gateway layer is built, the Lambda execution
|
||||
// role will supply credentials via the standard AWS environment variables. At that
|
||||
// point ensure the DocumentClient is constructed with the correct region and that
|
||||
// the table names are injected via environment variables (WORK_ORDERS_TABLE,
|
||||
// PURCHASE_ORDERS_TABLE, SITE_ASSIGNMENTS_TABLE) rather than hard-coded.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class RealDynamoClient implements InternalDataClient {
|
||||
// Table names — override via environment variables at Lambda deploy time.
|
||||
private readonly workOrdersTable: string;
|
||||
private readonly purchaseOrdersTable: string;
|
||||
private readonly siteAssignmentsTable: string;
|
||||
|
||||
// The DocumentClient is typed as `unknown` here to avoid importing the AWS SDK
|
||||
// at module scope. It is cast when needed inside each method.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private readonly ddb: any;
|
||||
|
||||
private constructor(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
ddb: any,
|
||||
workOrdersTable: string,
|
||||
purchaseOrdersTable: string,
|
||||
siteAssignmentsTable: string,
|
||||
) {
|
||||
this.ddb = ddb;
|
||||
this.workOrdersTable = workOrdersTable;
|
||||
this.purchaseOrdersTable = purchaseOrdersTable;
|
||||
this.siteAssignmentsTable = siteAssignmentsTable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory — the only place the AWS SDK DocumentClient is instantiated.
|
||||
* Calling this from a Lambda handler (not at module scope) is the correct pattern.
|
||||
*
|
||||
* NOTE: @aws-sdk/client-dynamodb and @aws-sdk/lib-dynamodb are intentionally absent
|
||||
* from package.json until the Lambda runtime bundle is assembled (see TODO above).
|
||||
* The module specifiers are stored in runtime variables so TypeScript does not attempt
|
||||
* static module-resolution at build time.
|
||||
*/
|
||||
static async create(): Promise<RealDynamoClient> {
|
||||
// Store specifiers in variables to prevent TypeScript static module resolution.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const dynImport = (s: string): Promise<any> => import(/* @vite-ignore */ s);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { DynamoDBClient } = (await dynImport('@aws-sdk/client-dynamodb')) as { DynamoDBClient: new (cfg: { region: string }) => any };
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { DynamoDBDocumentClient } = (await dynImport('@aws-sdk/lib-dynamodb')) as { DynamoDBDocumentClient: { from: (c: any) => any } };
|
||||
|
||||
const region = process.env['AWS_REGION'] ?? 'us-east-1';
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment
|
||||
const raw = new DynamoDBClient({ region });
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment
|
||||
const ddb = DynamoDBDocumentClient.from(raw);
|
||||
|
||||
return new RealDynamoClient(
|
||||
ddb,
|
||||
process.env['WORK_ORDERS_TABLE'] ?? 'WorkOrders',
|
||||
process.env['PURCHASE_ORDERS_TABLE'] ?? 'purchase-orders',
|
||||
process.env['SITE_ASSIGNMENTS_TABLE'] ?? 'SiteAssignments',
|
||||
);
|
||||
}
|
||||
|
||||
async getWorkOrder(workOrderId: string): Promise<WorkOrderRecord | null> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const dynImport = (s: string): Promise<any> => import(/* @vite-ignore */ s);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { GetCommand } = (await dynImport('@aws-sdk/lib-dynamodb')) as { GetCommand: new (i: any) => any };
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment
|
||||
const result = await this.ddb.send(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
||||
new GetCommand({ TableName: this.workOrdersTable, Key: { workOrderId } }),
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
return (result.Item as WorkOrderRecord) ?? null;
|
||||
}
|
||||
|
||||
async getPurchaseOrder(purchaseOrderId: string): Promise<PurchaseOrderRecord | null> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const dynImport = (s: string): Promise<any> => import(/* @vite-ignore */ s);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { GetCommand } = (await dynImport('@aws-sdk/lib-dynamodb')) as { GetCommand: new (i: any) => any };
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment
|
||||
const result = await this.ddb.send(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
||||
new GetCommand({ TableName: this.purchaseOrdersTable, Key: { purchaseOrderId } }),
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
return (result.Item as PurchaseOrderRecord) ?? null;
|
||||
}
|
||||
|
||||
async getSite(siteId: string): Promise<SiteRecord | null> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const dynImport = (s: string): Promise<any> => import(/* @vite-ignore */ s);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { GetCommand } = (await dynImport('@aws-sdk/lib-dynamodb')) as { GetCommand: new (i: any) => any };
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment
|
||||
const result = await this.ddb.send(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
||||
new GetCommand({ TableName: this.siteAssignmentsTable, Key: { siteId } }),
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
return (result.Item as SiteRecord) ?? null;
|
||||
}
|
||||
}
|
||||
19
packages/internal-data/src/index.ts
Normal file
19
packages/internal-data/src/index.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
/**
|
||||
* @sh-mcp/internal-data
|
||||
*
|
||||
* Exports the tool factory and the client interface/implementation.
|
||||
* Consumers call makeTools(client) with an injected InternalDataClient to obtain
|
||||
* the tool array that the MCP server (sh-mcp-ops) registers.
|
||||
*/
|
||||
|
||||
export { makeTools } from './tools.js';
|
||||
export type {
|
||||
LookupWorkOrderInput,
|
||||
LookupWorkOrderOutput,
|
||||
LookupPurchaseOrderInput,
|
||||
LookupPurchaseOrderOutput,
|
||||
LookupSiteInput,
|
||||
LookupSiteOutput,
|
||||
} from './tools.js';
|
||||
export type { InternalDataClient, WorkOrderRecord, PurchaseOrderRecord, SiteRecord } from './client.js';
|
||||
export { RealDynamoClient } from './client.js';
|
||||
228
packages/internal-data/src/tools.ts
Normal file
228
packages/internal-data/src/tools.ts
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
/**
|
||||
* Tool definitions for the internal-data package.
|
||||
*
|
||||
* Three read-only ops-tier tools backed by the injected InternalDataClient:
|
||||
* - lookup_work_order
|
||||
* - lookup_purchase_order
|
||||
* - lookup_site
|
||||
*
|
||||
* All three require the `ops:read` scope (§3 of design.md).
|
||||
*
|
||||
* Finance-tier note: these tools are ops-tier, so `redact()` is not called on
|
||||
* their responses. If a future refactor moves payment or financial amounts here,
|
||||
* call redact() on those fields per the design requirement that finance handlers
|
||||
* MUST redact sensitive output.
|
||||
*
|
||||
* The client is passed in at server startup (not imported from a module-level
|
||||
* singleton) so that tests can inject a mock without AWS credentials.
|
||||
*/
|
||||
|
||||
import { defineTool, requireScope } from '@sh-mcp/shared';
|
||||
import type { AuthContext } from '@sh-mcp/shared';
|
||||
import type { InternalDataClient } from './client.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input / output types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface LookupWorkOrderInput {
|
||||
workOrderId: string;
|
||||
}
|
||||
|
||||
export interface LookupWorkOrderOutput {
|
||||
found: boolean;
|
||||
workOrder?: {
|
||||
workOrderId: string;
|
||||
title: string;
|
||||
status: string;
|
||||
siteId?: string;
|
||||
assignedTo?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
description?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LookupPurchaseOrderInput {
|
||||
purchaseOrderId: string;
|
||||
}
|
||||
|
||||
export interface LookupPurchaseOrderOutput {
|
||||
found: boolean;
|
||||
purchaseOrder?: {
|
||||
purchaseOrderId: string;
|
||||
vendor: string;
|
||||
status: string;
|
||||
totalAmount?: number;
|
||||
currency?: string;
|
||||
issuedAt: string;
|
||||
updatedAt: string;
|
||||
lineItems?: Array<{ description: string; quantity: number; unitPrice: number }>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LookupSiteInput {
|
||||
siteId: string;
|
||||
}
|
||||
|
||||
export interface LookupSiteOutput {
|
||||
found: boolean;
|
||||
site?: {
|
||||
siteId: string;
|
||||
name: string;
|
||||
address?: string;
|
||||
region?: string;
|
||||
status: string;
|
||||
assignedTechnicians?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool factories — call makeTools(client) once at server startup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function makeTools(client: InternalDataClient) {
|
||||
const lookupWorkOrder = defineTool<LookupWorkOrderInput, LookupWorkOrderOutput>({
|
||||
name: 'lookup_work_order',
|
||||
description:
|
||||
'Retrieve a Sea Haven work order by its ID. Returns current status, assigned site, ' +
|
||||
'assigned technician, and description. Returns found=false when the ID does not exist.',
|
||||
tier: 'ops',
|
||||
requiredScope: 'ops:read',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['workOrderId'],
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
workOrderId: {
|
||||
type: 'string',
|
||||
description: 'The unique work-order identifier (e.g. WO-20240101-001).',
|
||||
minLength: 1,
|
||||
maxLength: 128,
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: async (
|
||||
input: LookupWorkOrderInput,
|
||||
ctx: AuthContext,
|
||||
): Promise<LookupWorkOrderOutput> => {
|
||||
requireScope(ctx, 'ops:read');
|
||||
|
||||
const record = await client.getWorkOrder(input.workOrderId);
|
||||
|
||||
if (record === null) {
|
||||
return { found: false };
|
||||
}
|
||||
|
||||
return {
|
||||
found: true,
|
||||
workOrder: {
|
||||
workOrderId: record.workOrderId,
|
||||
title: record.title,
|
||||
status: record.status,
|
||||
siteId: record.siteId,
|
||||
assignedTo: record.assignedTo,
|
||||
createdAt: record.createdAt,
|
||||
updatedAt: record.updatedAt,
|
||||
description: record.description,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const lookupPurchaseOrder = defineTool<LookupPurchaseOrderInput, LookupPurchaseOrderOutput>({
|
||||
name: 'lookup_purchase_order',
|
||||
description:
|
||||
'Retrieve a Sea Haven purchase order by its ID. Returns vendor name, status, total amount, ' +
|
||||
'and line items. Returns found=false when the ID does not exist.',
|
||||
tier: 'ops',
|
||||
requiredScope: 'ops:read',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['purchaseOrderId'],
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
purchaseOrderId: {
|
||||
type: 'string',
|
||||
description: 'The unique purchase-order identifier (e.g. PO-2024-00123).',
|
||||
minLength: 1,
|
||||
maxLength: 128,
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: async (
|
||||
input: LookupPurchaseOrderInput,
|
||||
ctx: AuthContext,
|
||||
): Promise<LookupPurchaseOrderOutput> => {
|
||||
requireScope(ctx, 'ops:read');
|
||||
|
||||
const record = await client.getPurchaseOrder(input.purchaseOrderId);
|
||||
|
||||
if (record === null) {
|
||||
return { found: false };
|
||||
}
|
||||
|
||||
return {
|
||||
found: true,
|
||||
purchaseOrder: {
|
||||
purchaseOrderId: record.purchaseOrderId,
|
||||
vendor: record.vendor,
|
||||
status: record.status,
|
||||
totalAmount: record.totalAmount,
|
||||
currency: record.currency,
|
||||
issuedAt: record.issuedAt,
|
||||
updatedAt: record.updatedAt,
|
||||
lineItems: record.lineItems,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const lookupSite = defineTool<LookupSiteInput, LookupSiteOutput>({
|
||||
name: 'lookup_site',
|
||||
description:
|
||||
'Retrieve a Sea Haven site assignment record by its ID. Returns site name, address, ' +
|
||||
'region, status, and assigned technicians. Returns found=false when the ID does not exist.',
|
||||
tier: 'ops',
|
||||
requiredScope: 'ops:read',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['siteId'],
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
siteId: {
|
||||
type: 'string',
|
||||
description: 'The unique site identifier (e.g. SITE-NYC-001).',
|
||||
minLength: 1,
|
||||
maxLength: 128,
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: async (
|
||||
input: LookupSiteInput,
|
||||
ctx: AuthContext,
|
||||
): Promise<LookupSiteOutput> => {
|
||||
requireScope(ctx, 'ops:read');
|
||||
|
||||
const record = await client.getSite(input.siteId);
|
||||
|
||||
if (record === null) {
|
||||
return { found: false };
|
||||
}
|
||||
|
||||
return {
|
||||
found: true,
|
||||
site: {
|
||||
siteId: record.siteId,
|
||||
name: record.name,
|
||||
address: record.address,
|
||||
region: record.region,
|
||||
status: record.status,
|
||||
assignedTechnicians: record.assignedTechnicians,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
return [lookupWorkOrder, lookupPurchaseOrder, lookupSite] as const;
|
||||
}
|
||||
331
packages/internal-data/test/internal-data.test.ts
Normal file
331
packages/internal-data/test/internal-data.test.ts
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
/**
|
||||
* Unit tests for @sh-mcp/internal-data tools.
|
||||
*
|
||||
* Strategy:
|
||||
* - All DynamoDB access is replaced by a typed mock that satisfies InternalDataClient.
|
||||
* - AuthContext is a mock with the required `ops:read` scope (or deliberately wrong
|
||||
* scopes for the scope-enforcement tests).
|
||||
* - No AWS SDK, no network, no environment variables required.
|
||||
*
|
||||
* Coverage:
|
||||
* - Happy path (record found) for each tool
|
||||
* - Empty result (record not found → found=false)
|
||||
* - Downstream error (client rejects) → handler re-throws
|
||||
* - Throttle / retry signal (ProvisionedThroughputExceededException)
|
||||
* - Scope enforcement (missing ops:read) → ScopeError thrown
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { InternalDataClient, WorkOrderRecord, PurchaseOrderRecord, SiteRecord } from '../src/client.js';
|
||||
import { makeTools } from '../src/tools.js';
|
||||
import type { AuthContext } from '@sh-mcp/shared';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeCtx(scopes: string[] = ['ops:read']): AuthContext {
|
||||
return {
|
||||
sub: 'lauren@seahavenind.com',
|
||||
scopes: scopes as AuthContext['scopes'],
|
||||
aud: 'sh-mcp-ops',
|
||||
};
|
||||
}
|
||||
|
||||
function makeMockClient(
|
||||
overrides: Partial<InternalDataClient> = {},
|
||||
): InternalDataClient {
|
||||
return {
|
||||
getWorkOrder: vi.fn().mockResolvedValue(null),
|
||||
getPurchaseOrder: vi.fn().mockResolvedValue(null),
|
||||
getSite: vi.fn().mockResolvedValue(null),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Fixture records
|
||||
const WORK_ORDER_RECORD: WorkOrderRecord = {
|
||||
workOrderId: 'WO-20240101-001',
|
||||
title: 'Replace HVAC filter – Building A',
|
||||
status: 'open',
|
||||
siteId: 'SITE-NYC-001',
|
||||
assignedTo: 'tech1@seahavenind.com',
|
||||
createdAt: '2024-01-01T10:00:00Z',
|
||||
updatedAt: '2024-01-02T08:00:00Z',
|
||||
description: 'Quarterly filter replacement per schedule.',
|
||||
};
|
||||
|
||||
const PURCHASE_ORDER_RECORD: PurchaseOrderRecord = {
|
||||
purchaseOrderId: 'PO-2024-00123',
|
||||
vendor: 'Acme Supplies',
|
||||
status: 'approved',
|
||||
totalAmount: 1250.0,
|
||||
currency: 'USD',
|
||||
issuedAt: '2024-01-05T09:00:00Z',
|
||||
updatedAt: '2024-01-06T11:00:00Z',
|
||||
lineItems: [
|
||||
{ description: 'HVAC filter 20x25', quantity: 10, unitPrice: 125.0 },
|
||||
],
|
||||
};
|
||||
|
||||
const SITE_RECORD: SiteRecord = {
|
||||
siteId: 'SITE-NYC-001',
|
||||
name: 'Sea Haven HQ – New York',
|
||||
address: '123 Main St, New York, NY 10001',
|
||||
region: 'northeast',
|
||||
status: 'active',
|
||||
assignedTechnicians: ['tech1@seahavenind.com'],
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests — lookup_work_order
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('lookup_work_order', () => {
|
||||
let client: InternalDataClient;
|
||||
let tool: ReturnType<typeof makeTools>[0];
|
||||
|
||||
beforeEach(() => {
|
||||
client = makeMockClient();
|
||||
[tool] = makeTools(client);
|
||||
});
|
||||
|
||||
it('happy path: returns found=true with shaped output for a known work order', async () => {
|
||||
(client.getWorkOrder as ReturnType<typeof vi.fn>).mockResolvedValue(WORK_ORDER_RECORD);
|
||||
|
||||
const result = await tool.handler({ workOrderId: 'WO-20240101-001' }, makeCtx());
|
||||
|
||||
expect(result.found).toBe(true);
|
||||
expect(result.workOrder).toMatchObject({
|
||||
workOrderId: 'WO-20240101-001',
|
||||
title: 'Replace HVAC filter – Building A',
|
||||
status: 'open',
|
||||
siteId: 'SITE-NYC-001',
|
||||
assignedTo: 'tech1@seahavenind.com',
|
||||
});
|
||||
expect(client.getWorkOrder).toHaveBeenCalledWith('WO-20240101-001');
|
||||
});
|
||||
|
||||
it('empty result: returns found=false when work order does not exist', async () => {
|
||||
(client.getWorkOrder as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
|
||||
const result = await tool.handler({ workOrderId: 'WO-NOTEXIST' }, makeCtx());
|
||||
|
||||
expect(result.found).toBe(false);
|
||||
expect(result.workOrder).toBeUndefined();
|
||||
});
|
||||
|
||||
it('error: re-throws when the DynamoDB client rejects', async () => {
|
||||
(client.getWorkOrder as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error('DynamoDB unavailable'),
|
||||
);
|
||||
|
||||
await expect(tool.handler({ workOrderId: 'WO-20240101-001' }, makeCtx())).rejects.toThrow(
|
||||
'DynamoDB unavailable',
|
||||
);
|
||||
});
|
||||
|
||||
it('throttle/retry: re-throws ProvisionedThroughputExceededException for the caller to retry', async () => {
|
||||
const throttleError = Object.assign(
|
||||
new Error('ProvisionedThroughputExceededException: Rate exceeded'),
|
||||
{ name: 'ProvisionedThroughputExceededException' },
|
||||
);
|
||||
(client.getWorkOrder as ReturnType<typeof vi.fn>).mockRejectedValue(throttleError);
|
||||
|
||||
await expect(tool.handler({ workOrderId: 'WO-20240101-001' }, makeCtx())).rejects.toMatchObject(
|
||||
{ name: 'ProvisionedThroughputExceededException' },
|
||||
);
|
||||
});
|
||||
|
||||
it('scope enforcement: throws when ops:read scope is missing', async () => {
|
||||
// The real shared requireScope throws a ScopeError; our mock honours the
|
||||
// same contract (imported from @sh-mcp/shared in the handler).
|
||||
await expect(
|
||||
tool.handler({ workOrderId: 'WO-20240101-001' }, makeCtx([])),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests — lookup_purchase_order
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('lookup_purchase_order', () => {
|
||||
let client: InternalDataClient;
|
||||
let tool: ReturnType<typeof makeTools>[1];
|
||||
|
||||
beforeEach(() => {
|
||||
client = makeMockClient();
|
||||
[, tool] = makeTools(client);
|
||||
});
|
||||
|
||||
it('happy path: returns found=true with shaped output for a known PO', async () => {
|
||||
(client.getPurchaseOrder as ReturnType<typeof vi.fn>).mockResolvedValue(PURCHASE_ORDER_RECORD);
|
||||
|
||||
const result = await tool.handler({ purchaseOrderId: 'PO-2024-00123' }, makeCtx());
|
||||
|
||||
expect(result.found).toBe(true);
|
||||
expect(result.purchaseOrder).toMatchObject({
|
||||
purchaseOrderId: 'PO-2024-00123',
|
||||
vendor: 'Acme Supplies',
|
||||
status: 'approved',
|
||||
totalAmount: 1250.0,
|
||||
currency: 'USD',
|
||||
});
|
||||
expect(result.purchaseOrder?.lineItems).toHaveLength(1);
|
||||
expect(client.getPurchaseOrder).toHaveBeenCalledWith('PO-2024-00123');
|
||||
});
|
||||
|
||||
it('empty result: returns found=false when PO does not exist', async () => {
|
||||
(client.getPurchaseOrder as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
|
||||
const result = await tool.handler({ purchaseOrderId: 'PO-NOTEXIST' }, makeCtx());
|
||||
|
||||
expect(result.found).toBe(false);
|
||||
expect(result.purchaseOrder).toBeUndefined();
|
||||
});
|
||||
|
||||
it('error: re-throws when the DynamoDB client rejects', async () => {
|
||||
(client.getPurchaseOrder as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error('ResourceNotFoundException'),
|
||||
);
|
||||
|
||||
await expect(
|
||||
tool.handler({ purchaseOrderId: 'PO-2024-00123' }, makeCtx()),
|
||||
).rejects.toThrow('ResourceNotFoundException');
|
||||
});
|
||||
|
||||
it('throttle/retry: re-throws ProvisionedThroughputExceededException', async () => {
|
||||
const throttleError = Object.assign(
|
||||
new Error('ProvisionedThroughputExceededException: Rate exceeded'),
|
||||
{ name: 'ProvisionedThroughputExceededException' },
|
||||
);
|
||||
(client.getPurchaseOrder as ReturnType<typeof vi.fn>).mockRejectedValue(throttleError);
|
||||
|
||||
await expect(
|
||||
tool.handler({ purchaseOrderId: 'PO-2024-00123' }, makeCtx()),
|
||||
).rejects.toMatchObject({ name: 'ProvisionedThroughputExceededException' });
|
||||
});
|
||||
|
||||
it('scope enforcement: throws when ops:read scope is missing', async () => {
|
||||
await expect(
|
||||
tool.handler({ purchaseOrderId: 'PO-2024-00123' }, makeCtx([])),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests — lookup_site
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('lookup_site', () => {
|
||||
let client: InternalDataClient;
|
||||
let tool: ReturnType<typeof makeTools>[2];
|
||||
|
||||
beforeEach(() => {
|
||||
client = makeMockClient();
|
||||
[, , tool] = makeTools(client);
|
||||
});
|
||||
|
||||
it('happy path: returns found=true with shaped output for a known site', async () => {
|
||||
(client.getSite as ReturnType<typeof vi.fn>).mockResolvedValue(SITE_RECORD);
|
||||
|
||||
const result = await tool.handler({ siteId: 'SITE-NYC-001' }, makeCtx());
|
||||
|
||||
expect(result.found).toBe(true);
|
||||
expect(result.site).toMatchObject({
|
||||
siteId: 'SITE-NYC-001',
|
||||
name: 'Sea Haven HQ – New York',
|
||||
address: '123 Main St, New York, NY 10001',
|
||||
region: 'northeast',
|
||||
status: 'active',
|
||||
});
|
||||
expect(result.site?.assignedTechnicians).toContain('tech1@seahavenind.com');
|
||||
expect(client.getSite).toHaveBeenCalledWith('SITE-NYC-001');
|
||||
});
|
||||
|
||||
it('empty result: returns found=false when site does not exist', async () => {
|
||||
(client.getSite as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
|
||||
const result = await tool.handler({ siteId: 'SITE-NOTEXIST' }, makeCtx());
|
||||
|
||||
expect(result.found).toBe(false);
|
||||
expect(result.site).toBeUndefined();
|
||||
});
|
||||
|
||||
it('error: re-throws when the DynamoDB client rejects', async () => {
|
||||
(client.getSite as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error('Internal server error'),
|
||||
);
|
||||
|
||||
await expect(
|
||||
tool.handler({ siteId: 'SITE-NYC-001' }, makeCtx()),
|
||||
).rejects.toThrow('Internal server error');
|
||||
});
|
||||
|
||||
it('throttle/retry: re-throws ProvisionedThroughputExceededException', async () => {
|
||||
const throttleError = Object.assign(
|
||||
new Error('ProvisionedThroughputExceededException: Rate exceeded'),
|
||||
{ name: 'ProvisionedThroughputExceededException' },
|
||||
);
|
||||
(client.getSite as ReturnType<typeof vi.fn>).mockRejectedValue(throttleError);
|
||||
|
||||
await expect(
|
||||
tool.handler({ siteId: 'SITE-NYC-001' }, makeCtx()),
|
||||
).rejects.toMatchObject({ name: 'ProvisionedThroughputExceededException' });
|
||||
});
|
||||
|
||||
it('scope enforcement: throws when ops:read scope is missing', async () => {
|
||||
await expect(
|
||||
tool.handler({ siteId: 'SITE-NYC-001' }, makeCtx([])),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('scope enforcement: throws when a finance scope is present but ops:read is absent', async () => {
|
||||
// A token from sh-mcp-finance has finance:read but no ops:read.
|
||||
await expect(
|
||||
tool.handler({ siteId: 'SITE-NYC-001' }, makeCtx(['finance:read'])),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool metadata contract tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('tool metadata', () => {
|
||||
it('all tools have required metadata fields', () => {
|
||||
const client = makeMockClient();
|
||||
const tools = makeTools(client);
|
||||
|
||||
for (const tool of tools) {
|
||||
expect(tool.name).toBeTruthy();
|
||||
expect(tool.description).toBeTruthy();
|
||||
expect(tool.tier).toBe('ops');
|
||||
expect(tool.requiredScope).toBe('ops:read');
|
||||
expect(tool.inputSchema).toBeTruthy();
|
||||
expect(typeof tool.handler).toBe('function');
|
||||
}
|
||||
});
|
||||
|
||||
it('tool names are the expected identifiers', () => {
|
||||
const client = makeMockClient();
|
||||
const tools = makeTools(client);
|
||||
const names = tools.map((t) => t.name);
|
||||
|
||||
expect(names).toEqual(['lookup_work_order', 'lookup_purchase_order', 'lookup_site']);
|
||||
});
|
||||
|
||||
it('input schemas declare required fields and disallow additional properties', () => {
|
||||
const client = makeMockClient();
|
||||
const [wo, po, site] = makeTools(client);
|
||||
|
||||
expect((wo.inputSchema as { required: string[] }).required).toContain('workOrderId');
|
||||
expect((po.inputSchema as { required: string[] }).required).toContain('purchaseOrderId');
|
||||
expect((site.inputSchema as { required: string[] }).required).toContain('siteId');
|
||||
|
||||
for (const tool of [wo, po, site]) {
|
||||
expect((tool.inputSchema as { additionalProperties: boolean }).additionalProperties).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
9
packages/internal-data/tsconfig.json
Normal file
9
packages/internal-data/tsconfig.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"declarationDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
34
packages/knowledge-base/package.json
Normal file
34
packages/knowledge-base/package.json
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"name": "@sh-mcp/knowledge-base",
|
||||
"version": "0.1.0",
|
||||
"description": "Sea Haven MCP knowledge-base tool — Bedrock KB retrieval behind an injected client interface",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --project tsconfig.json",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sh-mcp/shared": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@vitest/coverage-v8": "^2.0.0",
|
||||
"typescript": "^5.5.0",
|
||||
"vitest": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
}
|
||||
}
|
||||
150
packages/knowledge-base/src/client.ts
Normal file
150
packages/knowledge-base/src/client.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
/**
|
||||
* Knowledge-base client interface.
|
||||
*
|
||||
* The production implementation calls Amazon Bedrock Knowledge Base Retrieve API.
|
||||
* Swap the implementation by injecting a different KnowledgeBaseClient at the call
|
||||
* site — tests pass a mock, production code uses BedrockKnowledgeBaseClient.
|
||||
*
|
||||
* Future note: when migrating to Salesforce Data Cloud, replace
|
||||
* BedrockKnowledgeBaseClient with a DataCloudKnowledgeBaseClient that satisfies
|
||||
* the same KnowledgeBaseClient interface.
|
||||
*/
|
||||
|
||||
/** A single retrieval result returned by the knowledge base. */
|
||||
export interface KnowledgeBaseResult {
|
||||
/** Source document URI or human-readable reference (e.g. S3 URI, Notion page title). */
|
||||
source: string;
|
||||
/** Relevance score in the range [0, 1] as returned by the underlying retriever. */
|
||||
score: number;
|
||||
/** Text passage extracted from the source document. */
|
||||
passage: string;
|
||||
}
|
||||
|
||||
/** Options forwarded to the underlying retriever on each query. */
|
||||
export interface RetrieveOptions {
|
||||
/** Free-text query string. */
|
||||
query: string;
|
||||
/**
|
||||
* Maximum number of results to return.
|
||||
* Defaults to 5 if omitted; callers should not exceed 20.
|
||||
*/
|
||||
maxResults?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The interface every knowledge-base client must satisfy.
|
||||
* Production code uses BedrockKnowledgeBaseClient; tests supply a mock.
|
||||
*/
|
||||
export interface KnowledgeBaseClient {
|
||||
retrieve(options: RetrieveOptions): Promise<KnowledgeBaseResult[]>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Production implementation — Bedrock Knowledge Base Retrieve API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Configuration for the production Bedrock KB client.
|
||||
* All values come from environment variables; no defaults are hard-coded so
|
||||
* that the module can be imported without triggering any AWS calls.
|
||||
*/
|
||||
export interface BedrockKnowledgeBaseClientConfig {
|
||||
/** Bedrock Knowledge Base ID (e.g. "ABCD1234EF"). */
|
||||
knowledgeBaseId: string;
|
||||
/** AWS region (e.g. "us-east-1"). */
|
||||
region: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Production Bedrock KB client.
|
||||
*
|
||||
* The @aws-sdk/client-bedrock-agent-runtime package is imported LAZILY inside
|
||||
* retrieve() so that importing this module at the top level (e.g. in tests)
|
||||
* does NOT trigger any network activity or AWS credential resolution.
|
||||
*
|
||||
* TODO(auth-layer): once the deferred auth layer is in place, thread the
|
||||
* caller's AWS credentials / assumed role ARN through here if we want
|
||||
* per-user IAM audit trails on Bedrock calls.
|
||||
*/
|
||||
export class BedrockKnowledgeBaseClient implements KnowledgeBaseClient {
|
||||
private readonly config: BedrockKnowledgeBaseClientConfig;
|
||||
|
||||
constructor(config: BedrockKnowledgeBaseClientConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the Bedrock Retrieve API.
|
||||
*
|
||||
* The AWS SDK import is deferred to keep module load side-effect-free.
|
||||
* If the environment lacks AWS credentials this will throw at call time,
|
||||
* not at import time — which is the desired behaviour for testing.
|
||||
*/
|
||||
async retrieve(options: RetrieveOptions): Promise<KnowledgeBaseResult[]> {
|
||||
// @aws-sdk/client-bedrock-agent-runtime is intentionally absent from package.json
|
||||
// until the Lambda runtime bundle is assembled. The module specifier is stored in a
|
||||
// variable so TypeScript skips static module resolution at build time.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const dynImport = (s: string): Promise<any> => new Function('s', 'return import(s)')(s) as Promise<any>;
|
||||
|
||||
interface BedrockRetrievalResult {
|
||||
location?: { s3Location?: { uri?: string }; type?: string };
|
||||
score?: number;
|
||||
content?: { text?: string };
|
||||
}
|
||||
interface BedrockRetrieveResponse {
|
||||
retrievalResults?: BedrockRetrievalResult[];
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { BedrockAgentRuntimeClient, RetrieveCommand } = (await dynImport('@aws-sdk/client-bedrock-agent-runtime')) as {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
BedrockAgentRuntimeClient: new (cfg: { region: string }) => { send: (cmd: any) => Promise<BedrockRetrieveResponse> };
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
RetrieveCommand: new (input: any) => unknown;
|
||||
};
|
||||
|
||||
const client = new BedrockAgentRuntimeClient({ region: this.config.region });
|
||||
|
||||
const command = new RetrieveCommand({
|
||||
knowledgeBaseId: this.config.knowledgeBaseId,
|
||||
retrievalQuery: { text: options.query },
|
||||
retrievalConfiguration: {
|
||||
vectorSearchConfiguration: {
|
||||
numberOfResults: options.maxResults ?? 5,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const response = await client.send(command);
|
||||
const rawResults = response.retrievalResults ?? [];
|
||||
|
||||
return rawResults.map((r: BedrockRetrievalResult) => ({
|
||||
source: r.location?.s3Location?.uri ?? r.location?.type ?? 'unknown',
|
||||
score: r.score ?? 0,
|
||||
passage: r.content?.text ?? '',
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a production BedrockKnowledgeBaseClient from environment variables.
|
||||
*
|
||||
* Expected env vars:
|
||||
* KNOWLEDGE_BASE_ID — Bedrock Knowledge Base ID
|
||||
* AWS_REGION — AWS region (falls back to 'us-east-1')
|
||||
*
|
||||
* This factory is intentionally NOT called at module import time.
|
||||
*/
|
||||
export function createBedrockClientFromEnv(): BedrockKnowledgeBaseClient {
|
||||
const knowledgeBaseId = process.env['KNOWLEDGE_BASE_ID'];
|
||||
if (!knowledgeBaseId) {
|
||||
throw new Error(
|
||||
'KNOWLEDGE_BASE_ID environment variable is required for the production KB client'
|
||||
);
|
||||
}
|
||||
return new BedrockKnowledgeBaseClient({
|
||||
knowledgeBaseId,
|
||||
region: process.env['AWS_REGION'] ?? 'us-east-1',
|
||||
});
|
||||
}
|
||||
34
packages/knowledge-base/src/index.ts
Normal file
34
packages/knowledge-base/src/index.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/**
|
||||
* @sh-mcp/knowledge-base — public API
|
||||
*
|
||||
* Re-exports the tool factory and client types so downstream packages
|
||||
* (servers, jobs) can consume them without reaching into src/ internals.
|
||||
*
|
||||
* Quick-start (production):
|
||||
*
|
||||
* import { createKnowledgeBaseTools, createBedrockClientFromEnv } from '@sh-mcp/knowledge-base';
|
||||
* const tools = createKnowledgeBaseTools(createBedrockClientFromEnv());
|
||||
*
|
||||
* Quick-start (test):
|
||||
*
|
||||
* import { createKnowledgeBaseTools } from '@sh-mcp/knowledge-base';
|
||||
* const tools = createKnowledgeBaseTools(mockClient);
|
||||
*/
|
||||
|
||||
export { createKnowledgeBaseTools } from './tools.js';
|
||||
export type {
|
||||
SearchKnowledgeBaseInput,
|
||||
SearchKnowledgeBaseOutput,
|
||||
SearchKnowledgeBaseResultItem,
|
||||
} from './tools.js';
|
||||
|
||||
export {
|
||||
BedrockKnowledgeBaseClient,
|
||||
createBedrockClientFromEnv,
|
||||
} from './client.js';
|
||||
export type {
|
||||
KnowledgeBaseClient,
|
||||
KnowledgeBaseResult,
|
||||
RetrieveOptions,
|
||||
BedrockKnowledgeBaseClientConfig,
|
||||
} from './client.js';
|
||||
122
packages/knowledge-base/src/tools.ts
Normal file
122
packages/knowledge-base/src/tools.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
/**
|
||||
* MCP tool definitions for the knowledge-base package.
|
||||
*
|
||||
* Exports a factory so callers can inject the KnowledgeBaseClient
|
||||
* (production: BedrockKnowledgeBaseClient; tests: mock).
|
||||
*/
|
||||
|
||||
import { defineTool, requireScope } from '@sh-mcp/shared';
|
||||
import type { AuthContext, ToolDef } from '@sh-mcp/shared';
|
||||
import type { KnowledgeBaseClient } from './client.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input / output types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SearchKnowledgeBaseInput {
|
||||
/** Natural-language question or keyword query. */
|
||||
query: string;
|
||||
/**
|
||||
* Maximum number of passages to return (1–20).
|
||||
* Defaults to 5 when omitted.
|
||||
*/
|
||||
maxResults?: number;
|
||||
}
|
||||
|
||||
export interface SearchKnowledgeBaseResultItem {
|
||||
/** Source document reference (S3 URI, Notion page title, etc.). */
|
||||
source: string;
|
||||
/** Relevance score in [0, 1]. */
|
||||
score: number;
|
||||
/** Relevant text passage. */
|
||||
passage: string;
|
||||
}
|
||||
|
||||
export interface SearchKnowledgeBaseOutput {
|
||||
/** Ordered list of matching passages, most relevant first. */
|
||||
results: SearchKnowledgeBaseResultItem[];
|
||||
/** Total number of results returned. */
|
||||
count: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the knowledge-base tools array with the supplied client injected.
|
||||
*
|
||||
* Production usage:
|
||||
* import { createBedrockClientFromEnv } from './client.js';
|
||||
* const tools = createKnowledgeBaseTools(createBedrockClientFromEnv());
|
||||
*
|
||||
* Test usage:
|
||||
* const tools = createKnowledgeBaseTools(mockClient);
|
||||
*/
|
||||
export function createKnowledgeBaseTools(
|
||||
client: KnowledgeBaseClient
|
||||
): ToolDef<SearchKnowledgeBaseInput, SearchKnowledgeBaseOutput>[] {
|
||||
const searchKnowledgeBase = defineTool<
|
||||
SearchKnowledgeBaseInput,
|
||||
SearchKnowledgeBaseOutput
|
||||
>({
|
||||
name: 'search_knowledge_base',
|
||||
description:
|
||||
'Search the Sea Haven internal knowledge base for relevant information. ' +
|
||||
'The knowledge base is populated from Notion pages, purchase-order records, ' +
|
||||
'and work-order records. Use this tool to answer questions about company ' +
|
||||
'procedures, vendor details, site information, or historical work orders.',
|
||||
tier: 'ops',
|
||||
requiredScope: 'ops:read',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Natural-language question or keyword search query.',
|
||||
minLength: 1,
|
||||
maxLength: 1000,
|
||||
},
|
||||
maxResults: {
|
||||
type: 'integer',
|
||||
description:
|
||||
'Maximum number of passages to return (1–20). Defaults to 5.',
|
||||
minimum: 1,
|
||||
maximum: 20,
|
||||
default: 5,
|
||||
},
|
||||
},
|
||||
required: ['query'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
|
||||
async handler(
|
||||
input: SearchKnowledgeBaseInput,
|
||||
ctx: AuthContext
|
||||
): Promise<SearchKnowledgeBaseOutput> {
|
||||
// Server-side scope enforcement — never rely solely on UI tool-hiding.
|
||||
requireScope(ctx, 'ops:read');
|
||||
|
||||
// NOTE: This is an ops-tier tool. The output contains knowledge-base
|
||||
// passages (not finance data), so redact() is not called here.
|
||||
// Finance-tier tools in other packages MUST call redact() on sensitive fields.
|
||||
|
||||
const results = await client.retrieve({
|
||||
query: input.query,
|
||||
maxResults: input.maxResults ?? 5,
|
||||
});
|
||||
|
||||
return {
|
||||
results: results.map((r) => ({
|
||||
source: r.source,
|
||||
score: r.score,
|
||||
passage: r.passage,
|
||||
})),
|
||||
count: results.length,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
return [searchKnowledgeBase];
|
||||
}
|
||||
8
packages/knowledge-base/test/fixtures/password-reset.html
vendored
Normal file
8
packages/knowledge-base/test/fixtures/password-reset.html
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Password Reset</title></head>
|
||||
<body>
|
||||
<h1>How to Reset Your Password</h1>
|
||||
<p>Go to the login page and click <strong>Forgot Password</strong>. Enter your work email and follow the link we send you. Reset links expire after 30 minutes. If you do not receive an email, contact IT.</p>
|
||||
</body>
|
||||
</html>
|
||||
8
packages/knowledge-base/test/fixtures/pto-policy.html
vendored
Normal file
8
packages/knowledge-base/test/fixtures/pto-policy.html
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>PTO Policy</title></head>
|
||||
<body>
|
||||
<h1>Paid Time Off Policy</h1>
|
||||
<p>Full-time employees accrue 15 days of PTO per year. Submit requests at least two weeks in advance through the HR portal. Unused PTO rolls over up to a maximum of 5 days. Manager approval is required for all requests.</p>
|
||||
</body>
|
||||
</html>
|
||||
8
packages/knowledge-base/test/fixtures/return-policy.html
vendored
Normal file
8
packages/knowledge-base/test/fixtures/return-policy.html
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Return Policy</title></head>
|
||||
<body>
|
||||
<h1>Return Policy</h1>
|
||||
<p>Items may be returned within 30 days of purchase for a full refund, provided they are unused and in original packaging. A receipt or proof of purchase is required. Refunds are issued to the original payment method within 5 to 7 business days. Shipping charges are non-refundable.</p>
|
||||
</body>
|
||||
</html>
|
||||
302
packages/knowledge-base/test/knowledge-base.test.ts
Normal file
302
packages/knowledge-base/test/knowledge-base.test.ts
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
/**
|
||||
* Unit tests for @sh-mcp/knowledge-base.
|
||||
*
|
||||
* All tests use:
|
||||
* - A mock AuthContext with the required ops:read scope.
|
||||
* - A mock KnowledgeBaseClient — no AWS calls, no network.
|
||||
*
|
||||
* Coverage targets:
|
||||
* - Happy path: results returned and shaped correctly.
|
||||
* - Empty result: KB returns no matches.
|
||||
* - Scope enforcement: missing scope throws ScopeError.
|
||||
* - Client error: upstream error surfaces as a rejected promise.
|
||||
* - Throttle / retry: upstream ThrottlingException propagates (retry
|
||||
* logic, if added, would be tested here).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createKnowledgeBaseTools } from '../src/tools.js';
|
||||
import type { KnowledgeBaseClient, KnowledgeBaseResult } from '../src/client.js';
|
||||
import type { AuthContext } from '@sh-mcp/shared';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A valid AuthContext carrying the ops:read scope. */
|
||||
const authorisedCtx: AuthContext = {
|
||||
sub: 'lauren@seahavenind.com',
|
||||
scopes: ['ops:read'],
|
||||
aud: 'sh-mcp-ops',
|
||||
};
|
||||
|
||||
/** An AuthContext with no scopes — used to test scope enforcement. */
|
||||
const unauthorisedCtx: AuthContext = {
|
||||
sub: 'guest@example.com',
|
||||
scopes: [],
|
||||
aud: 'sh-mcp-ops',
|
||||
};
|
||||
|
||||
/** A finance-only AuthContext (finance:read but not ops:read). */
|
||||
const financeOnlyCtx: AuthContext = {
|
||||
sub: 'accounting@seahavenind.com',
|
||||
scopes: ['finance:read'],
|
||||
aud: 'sh-mcp-finance',
|
||||
};
|
||||
|
||||
/** Sample KB results returned by the mock. */
|
||||
const sampleResults: KnowledgeBaseResult[] = [
|
||||
{
|
||||
source: 's3://sh-kb-data/notion/procedures.md',
|
||||
score: 0.92,
|
||||
passage: 'All maintenance requests must be submitted via the work-order portal.',
|
||||
},
|
||||
{
|
||||
source: 's3://sh-kb-data/work-orders/WO-1042.md',
|
||||
score: 0.78,
|
||||
passage: 'Work order 1042: HVAC inspection completed 2025-11-15.',
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock client factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeMockClient(
|
||||
implementation?: Partial<KnowledgeBaseClient>
|
||||
): KnowledgeBaseClient {
|
||||
return {
|
||||
retrieve: vi.fn().mockResolvedValue(sampleResults),
|
||||
...implementation,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('search_knowledge_base', () => {
|
||||
let mockClient: KnowledgeBaseClient;
|
||||
|
||||
beforeEach(() => {
|
||||
mockClient = makeMockClient();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Happy path
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('returns shaped results for a valid query', async () => {
|
||||
const [tool] = createKnowledgeBaseTools(mockClient);
|
||||
|
||||
const output = await tool.handler(
|
||||
{ query: 'maintenance procedures', maxResults: 5 },
|
||||
authorisedCtx
|
||||
);
|
||||
|
||||
expect(output.count).toBe(2);
|
||||
expect(output.results).toHaveLength(2);
|
||||
|
||||
const [first] = output.results;
|
||||
expect(first.source).toBe('s3://sh-kb-data/notion/procedures.md');
|
||||
expect(first.score).toBe(0.92);
|
||||
expect(first.passage).toContain('maintenance requests');
|
||||
});
|
||||
|
||||
it('passes query and maxResults through to the client', async () => {
|
||||
const [tool] = createKnowledgeBaseTools(mockClient);
|
||||
|
||||
await tool.handler({ query: 'HVAC vendors', maxResults: 3 }, authorisedCtx);
|
||||
|
||||
expect(mockClient.retrieve).toHaveBeenCalledOnce();
|
||||
expect(mockClient.retrieve).toHaveBeenCalledWith({
|
||||
query: 'HVAC vendors',
|
||||
maxResults: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults maxResults to 5 when omitted', async () => {
|
||||
const [tool] = createKnowledgeBaseTools(mockClient);
|
||||
|
||||
await tool.handler({ query: 'fire safety' }, authorisedCtx);
|
||||
|
||||
expect(mockClient.retrieve).toHaveBeenCalledWith({
|
||||
query: 'fire safety',
|
||||
maxResults: 5,
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Tool metadata assertions
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('has correct tool metadata', () => {
|
||||
const [tool] = createKnowledgeBaseTools(mockClient);
|
||||
|
||||
expect(tool.name).toBe('search_knowledge_base');
|
||||
expect(tool.tier).toBe('ops');
|
||||
expect(tool.requiredScope).toBe('ops:read');
|
||||
expect(tool.description).toMatch(/knowledge base/i);
|
||||
});
|
||||
|
||||
it('exports exactly one tool', () => {
|
||||
const tools = createKnowledgeBaseTools(mockClient);
|
||||
expect(tools).toHaveLength(1);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Empty result
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('returns an empty results array when the KB finds no matches', async () => {
|
||||
const emptyClient = makeMockClient({
|
||||
retrieve: vi.fn().mockResolvedValue([]),
|
||||
});
|
||||
const [tool] = createKnowledgeBaseTools(emptyClient);
|
||||
|
||||
const output = await tool.handler(
|
||||
{ query: 'nonexistent topic xyz' },
|
||||
authorisedCtx
|
||||
);
|
||||
|
||||
expect(output.count).toBe(0);
|
||||
expect(output.results).toEqual([]);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Scope enforcement
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('throws ScopeError when the caller has no scopes', async () => {
|
||||
const [tool] = createKnowledgeBaseTools(mockClient);
|
||||
|
||||
await expect(
|
||||
tool.handler({ query: 'anything' }, unauthorisedCtx)
|
||||
).rejects.toThrow();
|
||||
|
||||
// The client must NOT be called when auth fails.
|
||||
expect(mockClient.retrieve).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws ScopeError when the caller only has a finance scope (not ops:read)', async () => {
|
||||
const [tool] = createKnowledgeBaseTools(mockClient);
|
||||
|
||||
await expect(
|
||||
tool.handler({ query: 'anything' }, financeOnlyCtx)
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(mockClient.retrieve).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('succeeds when the caller has ops:read among multiple scopes', async () => {
|
||||
const multiScopeCtx: AuthContext = {
|
||||
sub: 'adam@seahavenind.com',
|
||||
scopes: ['ops:read', 'ops:tasks', 'finance:read', 'finance:admin'],
|
||||
aud: 'sh-mcp-ops',
|
||||
};
|
||||
const [tool] = createKnowledgeBaseTools(mockClient);
|
||||
|
||||
const output = await tool.handler({ query: 'anything' }, multiScopeCtx);
|
||||
expect(output.count).toBe(2);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Client error
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('surfaces a client error as a rejected promise', async () => {
|
||||
const errorClient = makeMockClient({
|
||||
retrieve: vi.fn().mockRejectedValue(new Error('Bedrock Retrieve failed')),
|
||||
});
|
||||
const [tool] = createKnowledgeBaseTools(errorClient);
|
||||
|
||||
await expect(
|
||||
tool.handler({ query: 'HVAC' }, authorisedCtx)
|
||||
).rejects.toThrow('Bedrock Retrieve failed');
|
||||
});
|
||||
|
||||
it('surfaces an unexpected error type without swallowing it', async () => {
|
||||
const weirdClient = makeMockClient({
|
||||
retrieve: vi.fn().mockRejectedValue('string error'),
|
||||
});
|
||||
const [tool] = createKnowledgeBaseTools(weirdClient);
|
||||
|
||||
await expect(
|
||||
tool.handler({ query: 'test' }, authorisedCtx)
|
||||
).rejects.toBe('string error');
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Throttle / retry
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('propagates a ThrottlingException from the client', async () => {
|
||||
// Simulate the shape Bedrock SDK throws for throttling.
|
||||
const throttleError = Object.assign(new Error('Too many requests'), {
|
||||
name: 'ThrottlingException',
|
||||
$fault: 'client',
|
||||
$retryable: { throttling: true },
|
||||
});
|
||||
|
||||
const throttledClient = makeMockClient({
|
||||
retrieve: vi.fn().mockRejectedValue(throttleError),
|
||||
});
|
||||
const [tool] = createKnowledgeBaseTools(throttledClient);
|
||||
|
||||
const rejection = await tool
|
||||
.handler({ query: 'anything' }, authorisedCtx)
|
||||
.catch((e: unknown) => e);
|
||||
|
||||
expect((rejection as Error).name).toBe('ThrottlingException');
|
||||
});
|
||||
|
||||
it('propagates throttle on first call (retry logic placeholder)', async () => {
|
||||
// When retry logic is added (e.g. exponential back-off wrapper), update
|
||||
// this test to assert the mock is called N times and eventually succeeds.
|
||||
// For now assert the error propagates unchanged so the server layer can
|
||||
// apply its own retry strategy.
|
||||
const throttleError = Object.assign(new Error('Too many requests'), {
|
||||
name: 'ThrottlingException',
|
||||
});
|
||||
|
||||
const client = makeMockClient({
|
||||
retrieve: vi.fn().mockRejectedValue(throttleError),
|
||||
});
|
||||
const [tool] = createKnowledgeBaseTools(client);
|
||||
|
||||
await expect(
|
||||
tool.handler({ query: 'test' }, authorisedCtx)
|
||||
).rejects.toMatchObject({ name: 'ThrottlingException' });
|
||||
|
||||
// Exactly one attempt — no retry implemented yet.
|
||||
expect(client.retrieve).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Input schema assertions (contract)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('declares query as a required string in the inputSchema', () => {
|
||||
const [tool] = createKnowledgeBaseTools(mockClient);
|
||||
const schema = tool.inputSchema as {
|
||||
required: string[];
|
||||
properties: Record<string, { type: string }>;
|
||||
};
|
||||
|
||||
expect(schema.required).toContain('query');
|
||||
expect(schema.properties['query'].type).toBe('string');
|
||||
});
|
||||
|
||||
it('declares maxResults as an optional integer in the inputSchema', () => {
|
||||
const [tool] = createKnowledgeBaseTools(mockClient);
|
||||
const schema = tool.inputSchema as {
|
||||
required: string[];
|
||||
properties: Record<string, { type: string; minimum: number; maximum: number }>;
|
||||
};
|
||||
|
||||
expect(schema.required).not.toContain('maxResults');
|
||||
expect(schema.properties['maxResults'].type).toBe('integer');
|
||||
expect(schema.properties['maxResults'].minimum).toBe(1);
|
||||
expect(schema.properties['maxResults'].maximum).toBe(20);
|
||||
});
|
||||
});
|
||||
9
packages/knowledge-base/tsconfig.json
Normal file
9
packages/knowledge-base/tsconfig.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"declarationDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
34
packages/payments/package.json
Normal file
34
packages/payments/package.json
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"name": "@sh-mcp/payments",
|
||||
"version": "0.1.0",
|
||||
"description": "Sea Haven MCP payments tools — PaymentsDashboard lookups",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --project tsconfig.json",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sh-mcp/shared": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@vitest/coverage-v8": "^2.0.0",
|
||||
"typescript": "^5.5.0",
|
||||
"vitest": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
}
|
||||
}
|
||||
122
packages/payments/src/client.ts
Normal file
122
packages/payments/src/client.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
/**
|
||||
* PaymentsClient interface + stub implementation.
|
||||
*
|
||||
* The real implementation would use the AWS SDK DynamoDB DocumentClient
|
||||
* targeting the PaymentsDashboard table. That call is clearly marked below
|
||||
* and guarded behind the interface so tests can inject a mock without any
|
||||
* AWS credentials or network access at import time.
|
||||
*
|
||||
* TODO(auth-layer): when the real DynamoDB client is wired, pull the table
|
||||
* name and region from environment variables set by the CDK stack rather than
|
||||
* hard-coding them here. Credentials must come from the Lambda execution
|
||||
* role (no explicit key/secret in code or Secrets Manager for IAM-auth calls).
|
||||
*/
|
||||
|
||||
export interface Payment {
|
||||
paymentId: string;
|
||||
vendor: string;
|
||||
vendorContact?: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
invoiceNumber?: string;
|
||||
checkNumber?: string;
|
||||
/** ISO-8601 date string */
|
||||
paymentDate: string;
|
||||
status: "pending" | "cleared" | "voided" | "failed";
|
||||
/** Bank account number — MUST be redacted before leaving the server */
|
||||
bankAccountNumber?: string;
|
||||
/** Bank routing number — MUST be redacted before leaving the server */
|
||||
bankRoutingNumber?: string;
|
||||
/** Card number (last-four or full) — MUST be redacted before leaving the server */
|
||||
cardNumber?: string;
|
||||
/** ACH or wire memo */
|
||||
memo?: string;
|
||||
}
|
||||
|
||||
export interface PaymentsQueryOptions {
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The contract every PaymentsDashboard client must satisfy.
|
||||
* Tests inject a MockPaymentsClient; production injects DynamoPaymentsClient.
|
||||
*/
|
||||
export interface PaymentsClient {
|
||||
/** Return all payments for a given vendor name (case-insensitive prefix match). */
|
||||
getByVendor(
|
||||
vendor: string,
|
||||
opts?: PaymentsQueryOptions,
|
||||
): Promise<Payment[]>;
|
||||
|
||||
/** Return the payment(s) matching an invoice number. */
|
||||
getByInvoice(
|
||||
invoiceNumber: string,
|
||||
opts?: PaymentsQueryOptions,
|
||||
): Promise<Payment[]>;
|
||||
|
||||
/** Return the payment matching a check number. */
|
||||
getByCheck(
|
||||
checkNumber: string,
|
||||
opts?: PaymentsQueryOptions,
|
||||
): Promise<Payment[]>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stub production implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Thin wrapper around the DynamoDB PaymentsDashboard table.
|
||||
*
|
||||
* STUBBED: the actual DynamoDB calls are replaced with a thrown error so that
|
||||
* this file is safe to import in any environment without AWS credentials. To
|
||||
* activate the real implementation:
|
||||
* 1. npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
|
||||
* 2. Replace each `throw new Error("STUB")` block with the real query.
|
||||
*
|
||||
* TODO(real-impl): implement DynamoDB GSI queries:
|
||||
* - VendorIndex (pk = vendor_normalized)
|
||||
* - InvoiceIndex (pk = invoiceNumber)
|
||||
* - CheckIndex (pk = checkNumber)
|
||||
*/
|
||||
export class DynamoPaymentsClient implements PaymentsClient {
|
||||
private readonly tableName: string;
|
||||
|
||||
constructor(tableName = process.env["PAYMENTS_TABLE"] ?? "PaymentsDashboard") {
|
||||
this.tableName = tableName;
|
||||
// The DynamoDB DocumentClient is intentionally NOT instantiated here to
|
||||
// avoid any AWS SDK import side-effects at module load time. Instantiate
|
||||
// it lazily inside each method once the real implementation is added.
|
||||
void this.tableName; // suppress unused-var lint until real impl lands
|
||||
}
|
||||
|
||||
async getByVendor(
|
||||
_vendor: string,
|
||||
_opts?: PaymentsQueryOptions,
|
||||
): Promise<Payment[]> {
|
||||
// TODO(real-impl): query VendorIndex GSI with vendor_normalized = vendor.toLowerCase()
|
||||
throw new Error(
|
||||
"DynamoPaymentsClient is a stub — inject a real or mock client instead.",
|
||||
);
|
||||
}
|
||||
|
||||
async getByInvoice(
|
||||
_invoiceNumber: string,
|
||||
_opts?: PaymentsQueryOptions,
|
||||
): Promise<Payment[]> {
|
||||
// TODO(real-impl): query InvoiceIndex GSI with invoiceNumber = invoiceNumber
|
||||
throw new Error(
|
||||
"DynamoPaymentsClient is a stub — inject a real or mock client instead.",
|
||||
);
|
||||
}
|
||||
|
||||
async getByCheck(
|
||||
_checkNumber: string,
|
||||
_opts?: PaymentsQueryOptions,
|
||||
): Promise<Payment[]> {
|
||||
// TODO(real-impl): query CheckIndex GSI with checkNumber = checkNumber
|
||||
throw new Error(
|
||||
"DynamoPaymentsClient is a stub — inject a real or mock client instead.",
|
||||
);
|
||||
}
|
||||
}
|
||||
52
packages/payments/src/index.ts
Normal file
52
packages/payments/src/index.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* @sh-mcp/payments public API
|
||||
*
|
||||
* Exports the payments tool factory functions and the tool array builder.
|
||||
* The wire transport (OpenAPI / MCP) is generated from the registry; this
|
||||
* package only defines tools.
|
||||
*
|
||||
* Usage:
|
||||
* import { makePaymentsTools } from '@sh-mcp/payments';
|
||||
* import { DynamoPaymentsClient } from '@sh-mcp/payments/client';
|
||||
*
|
||||
* const tools = makePaymentsTools(new DynamoPaymentsClient());
|
||||
* // register tools with the server registry
|
||||
*/
|
||||
|
||||
export type { PaymentsClient, Payment, PaymentsQueryOptions } from "./client.js";
|
||||
export { DynamoPaymentsClient } from "./client.js";
|
||||
|
||||
export type {
|
||||
LookupByVendorInput,
|
||||
LookupByVendorOutput,
|
||||
LookupByInvoiceInput,
|
||||
LookupByInvoiceOutput,
|
||||
LookupByCheckInput,
|
||||
LookupByCheckOutput,
|
||||
} from "./tools.js";
|
||||
export {
|
||||
makeLookupByVendorTool,
|
||||
makeLookupByInvoiceTool,
|
||||
makeLookupByCheckTool,
|
||||
} from "./tools.js";
|
||||
|
||||
import type { PaymentsClient } from "./client.js";
|
||||
import {
|
||||
makeLookupByVendorTool,
|
||||
makeLookupByInvoiceTool,
|
||||
makeLookupByCheckTool,
|
||||
} from "./tools.js";
|
||||
|
||||
/**
|
||||
* Build the full payments tool array for a given client.
|
||||
*
|
||||
* @param client - A PaymentsClient implementation (real or mock).
|
||||
* @returns Array of ToolDef objects ready for registration with the server.
|
||||
*/
|
||||
export function makePaymentsTools(client: PaymentsClient) {
|
||||
return [
|
||||
makeLookupByVendorTool(client),
|
||||
makeLookupByInvoiceTool(client),
|
||||
makeLookupByCheckTool(client),
|
||||
] as const;
|
||||
}
|
||||
208
packages/payments/src/tools.ts
Normal file
208
packages/payments/src/tools.ts
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
/**
|
||||
* MCP tool definitions for the @sh-mcp/payments package.
|
||||
*
|
||||
* All three tools are finance-tier, require the `finance:read` scope, and MUST
|
||||
* call redact() on every sensitive field (bank account, routing, card, SSN)
|
||||
* before returning output. Vendor names and contact details are left intact
|
||||
* per the redact() contract.
|
||||
*
|
||||
* The PaymentsClient is injected via factory functions so tests can supply a
|
||||
* mock without AWS credentials or network access.
|
||||
*
|
||||
* TODO(auth-layer): requireScope() currently only checks the scopes array on
|
||||
* the AuthContext object. Full JWT signature verification, issuer validation,
|
||||
* audience binding (aud === 'sh-mcp-finance'), and token-expiry enforcement are
|
||||
* handled by the DEFERRED centralized auth middleware in @sh-mcp/shared. Do
|
||||
* NOT remove the requireScope() call — it is the server-side enforcement gate
|
||||
* that must remain authoritative even after the middleware is in place.
|
||||
*/
|
||||
|
||||
import { defineTool, requireScope, redact, maskValue } from "@sh-mcp/shared";
|
||||
import type { AuthContext } from "@sh-mcp/shared";
|
||||
import type { PaymentsClient, Payment } from "./client.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared output shaping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Redact all sensitive fields on a Payment object in-place and return a new
|
||||
* object safe to return to callers / the Slack agent.
|
||||
*
|
||||
* Fields redacted: bankAccountNumber, bankRoutingNumber, cardNumber.
|
||||
* Fields left intact: vendor, vendorContact, memo (may contain vendor info).
|
||||
*/
|
||||
function redactPayment(p: Payment): Payment {
|
||||
// redact() handles pattern-based redaction for any embedded PII in text fields.
|
||||
// maskValue() is used additionally for standalone sensitive field values that
|
||||
// redact() cannot match without keyword context (bare account/routing numbers).
|
||||
return {
|
||||
...p,
|
||||
...(p.bankAccountNumber !== undefined && {
|
||||
bankAccountNumber: maskValue(redact(p.bankAccountNumber)),
|
||||
}),
|
||||
...(p.bankRoutingNumber !== undefined && {
|
||||
bankRoutingNumber: maskValue(redact(p.bankRoutingNumber)),
|
||||
}),
|
||||
...(p.cardNumber !== undefined && {
|
||||
// redact() handles Luhn-valid card numbers; maskValue() covers the rest.
|
||||
cardNumber: maskValue(redact(p.cardNumber)),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// lookup_payment_by_vendor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface LookupByVendorInput {
|
||||
vendor: string;
|
||||
/** Maximum number of results to return. Defaults to 20, max 100. */
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface LookupByVendorOutput {
|
||||
payments: Payment[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export function makeLookupByVendorTool(client: PaymentsClient) {
|
||||
return defineTool<LookupByVendorInput, LookupByVendorOutput>({
|
||||
name: "lookup_payment_by_vendor",
|
||||
description:
|
||||
"Look up PaymentsDashboard records for a given vendor name. " +
|
||||
"Returns cleared, pending, voided, and failed payments. " +
|
||||
"Sensitive bank, routing, and card fields are masked in the response.",
|
||||
tier: "finance",
|
||||
requiredScope: "finance:read",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
vendor: {
|
||||
type: "string",
|
||||
description:
|
||||
"Vendor name to search for (case-insensitive prefix match).",
|
||||
minLength: 1,
|
||||
maxLength: 200,
|
||||
},
|
||||
limit: {
|
||||
type: "integer",
|
||||
description: "Maximum number of results to return (1–100). Defaults to 20.",
|
||||
minimum: 1,
|
||||
maximum: 100,
|
||||
default: 20,
|
||||
},
|
||||
},
|
||||
required: ["vendor"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
async handler(
|
||||
input: LookupByVendorInput,
|
||||
ctx: AuthContext,
|
||||
): Promise<LookupByVendorOutput> {
|
||||
requireScope(ctx, "finance:read");
|
||||
|
||||
const limit = Math.min(input.limit ?? 20, 100);
|
||||
const payments = await client.getByVendor(input.vendor, { limit });
|
||||
const redacted = payments.map(redactPayment);
|
||||
|
||||
return { payments: redacted, count: redacted.length };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// lookup_payment_by_invoice
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface LookupByInvoiceInput {
|
||||
invoiceNumber: string;
|
||||
}
|
||||
|
||||
export interface LookupByInvoiceOutput {
|
||||
payments: Payment[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export function makeLookupByInvoiceTool(client: PaymentsClient) {
|
||||
return defineTool<LookupByInvoiceInput, LookupByInvoiceOutput>({
|
||||
name: "lookup_payment_by_invoice",
|
||||
description:
|
||||
"Look up a payment in PaymentsDashboard by invoice number. " +
|
||||
"Sensitive bank, routing, and card fields are masked in the response.",
|
||||
tier: "finance",
|
||||
requiredScope: "finance:read",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
invoiceNumber: {
|
||||
type: "string",
|
||||
description: "The invoice number to look up (exact match).",
|
||||
minLength: 1,
|
||||
maxLength: 100,
|
||||
},
|
||||
},
|
||||
required: ["invoiceNumber"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
async handler(
|
||||
input: LookupByInvoiceInput,
|
||||
ctx: AuthContext,
|
||||
): Promise<LookupByInvoiceOutput> {
|
||||
requireScope(ctx, "finance:read");
|
||||
|
||||
const payments = await client.getByInvoice(input.invoiceNumber);
|
||||
const redacted = payments.map(redactPayment);
|
||||
|
||||
return { payments: redacted, count: redacted.length };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// lookup_payment_by_check
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface LookupByCheckInput {
|
||||
checkNumber: string;
|
||||
}
|
||||
|
||||
export interface LookupByCheckOutput {
|
||||
payments: Payment[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export function makeLookupByCheckTool(client: PaymentsClient) {
|
||||
return defineTool<LookupByCheckInput, LookupByCheckOutput>({
|
||||
name: "lookup_payment_by_check",
|
||||
description:
|
||||
"Look up a payment in PaymentsDashboard by check number. " +
|
||||
"Sensitive bank, routing, and card fields are masked in the response.",
|
||||
tier: "finance",
|
||||
requiredScope: "finance:read",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
checkNumber: {
|
||||
type: "string",
|
||||
description: "The check number to look up (exact match).",
|
||||
minLength: 1,
|
||||
maxLength: 50,
|
||||
},
|
||||
},
|
||||
required: ["checkNumber"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
async handler(
|
||||
input: LookupByCheckInput,
|
||||
ctx: AuthContext,
|
||||
): Promise<LookupByCheckOutput> {
|
||||
requireScope(ctx, "finance:read");
|
||||
|
||||
const payments = await client.getByCheck(input.checkNumber);
|
||||
const redacted = payments.map(redactPayment);
|
||||
|
||||
return { payments: redacted, count: redacted.length };
|
||||
},
|
||||
});
|
||||
}
|
||||
467
packages/payments/test/payments.test.ts
Normal file
467
packages/payments/test/payments.test.ts
Normal file
|
|
@ -0,0 +1,467 @@
|
|||
/**
|
||||
* Unit tests for @sh-mcp/payments tools.
|
||||
*
|
||||
* All tests use:
|
||||
* - A mock AuthContext with finance:read scope (unless testing scope rejection).
|
||||
* - A mock PaymentsClient — no real DynamoDB or AWS calls.
|
||||
*
|
||||
* Coverage targets:
|
||||
* - Happy path for each tool
|
||||
* - Empty result set
|
||||
* - Client error propagation
|
||||
* - Throttle / retry simulation (transient error then success)
|
||||
* - Scope enforcement: missing scope throws ScopeError
|
||||
* - Redaction: bank account, routing, card numbers are masked; vendor names are not
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { AuthContext } from "@sh-mcp/shared";
|
||||
import { requireScope } from "@sh-mcp/shared";
|
||||
import type { PaymentsClient, Payment } from "../src/client.js";
|
||||
import {
|
||||
makeLookupByVendorTool,
|
||||
makeLookupByInvoiceTool,
|
||||
makeLookupByCheckTool,
|
||||
} from "../src/tools.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared test fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A fully-scoped finance context — happy-path default. */
|
||||
const financeCtx: AuthContext = {
|
||||
sub: "adam@seahavenind.com",
|
||||
scopes: ["finance:read"],
|
||||
aud: "sh-mcp-finance",
|
||||
};
|
||||
|
||||
/** A context that lacks finance:read — for scope-rejection tests. */
|
||||
const opsOnlyCtx: AuthContext = {
|
||||
sub: "staff@seahavenind.com",
|
||||
scopes: ["ops:read"],
|
||||
aud: "sh-mcp-ops",
|
||||
};
|
||||
|
||||
const samplePayment: Payment = {
|
||||
paymentId: "pmt-001",
|
||||
vendor: "Acme Electrical Supply",
|
||||
vendorContact: "billing@acme.example.com",
|
||||
amount: 4250.0,
|
||||
currency: "USD",
|
||||
invoiceNumber: "INV-2026-0042",
|
||||
checkNumber: "10412",
|
||||
paymentDate: "2026-05-15",
|
||||
status: "cleared",
|
||||
bankAccountNumber: "123456789",
|
||||
bankRoutingNumber: "021000021",
|
||||
cardNumber: "4111111111111111",
|
||||
memo: "Electrical supplies for Ronkonkoma site",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock client builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeMockClient(overrides?: Partial<PaymentsClient>): PaymentsClient {
|
||||
return {
|
||||
getByVendor: vi.fn().mockResolvedValue([samplePayment]),
|
||||
getByInvoice: vi.fn().mockResolvedValue([samplePayment]),
|
||||
getByCheck: vi.fn().mockResolvedValue([samplePayment]),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// lookup_payment_by_vendor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("lookup_payment_by_vendor", () => {
|
||||
let client: PaymentsClient;
|
||||
|
||||
beforeEach(() => {
|
||||
client = makeMockClient();
|
||||
});
|
||||
|
||||
it("returns payments and redacts sensitive fields on the happy path", async () => {
|
||||
const tool = makeLookupByVendorTool(client);
|
||||
const result = await tool.handler({ vendor: "Acme" }, financeCtx);
|
||||
|
||||
expect(result.count).toBe(1);
|
||||
expect(result.payments).toHaveLength(1);
|
||||
|
||||
const p = result.payments[0]!;
|
||||
// Vendor name and contact must be intact.
|
||||
expect(p.vendor).toBe("Acme Electrical Supply");
|
||||
expect(p.vendorContact).toBe("billing@acme.example.com");
|
||||
|
||||
// Sensitive fields must be redacted (not equal to the originals).
|
||||
expect(p.bankAccountNumber).not.toBe(samplePayment.bankAccountNumber);
|
||||
expect(p.bankRoutingNumber).not.toBe(samplePayment.bankRoutingNumber);
|
||||
expect(p.cardNumber).not.toBe(samplePayment.cardNumber);
|
||||
|
||||
// Non-sensitive fields must be preserved.
|
||||
expect(p.amount).toBe(4250.0);
|
||||
expect(p.status).toBe("cleared");
|
||||
expect(p.paymentDate).toBe("2026-05-15");
|
||||
});
|
||||
|
||||
it("forwards the vendor string and limit to the client", async () => {
|
||||
const tool = makeLookupByVendorTool(client);
|
||||
await tool.handler({ vendor: "Acme", limit: 5 }, financeCtx);
|
||||
|
||||
expect(client.getByVendor).toHaveBeenCalledWith("Acme", { limit: 5 });
|
||||
});
|
||||
|
||||
it("caps limit at 100", async () => {
|
||||
const tool = makeLookupByVendorTool(client);
|
||||
await tool.handler({ vendor: "Acme", limit: 9999 }, financeCtx);
|
||||
|
||||
expect(client.getByVendor).toHaveBeenCalledWith("Acme", { limit: 100 });
|
||||
});
|
||||
|
||||
it("returns empty result when the client returns no payments", async () => {
|
||||
client = makeMockClient({
|
||||
getByVendor: vi.fn().mockResolvedValue([]),
|
||||
});
|
||||
const tool = makeLookupByVendorTool(client);
|
||||
const result = await tool.handler({ vendor: "Unknown Vendor" }, financeCtx);
|
||||
|
||||
expect(result.count).toBe(0);
|
||||
expect(result.payments).toEqual([]);
|
||||
});
|
||||
|
||||
it("propagates a client error", async () => {
|
||||
client = makeMockClient({
|
||||
getByVendor: vi.fn().mockRejectedValue(new Error("DynamoDB unavailable")),
|
||||
});
|
||||
const tool = makeLookupByVendorTool(client);
|
||||
|
||||
await expect(
|
||||
tool.handler({ vendor: "Acme" }, financeCtx),
|
||||
).rejects.toThrow("DynamoDB unavailable");
|
||||
});
|
||||
|
||||
it("retries and succeeds after a transient throttle error", async () => {
|
||||
// Simulate a throttle on the first call, success on the second.
|
||||
const throttleError = Object.assign(
|
||||
new Error("ProvisionedThroughputExceededException"),
|
||||
{ name: "ProvisionedThroughputExceededException" },
|
||||
);
|
||||
const getByVendor = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(throttleError)
|
||||
.mockResolvedValueOnce([samplePayment]);
|
||||
|
||||
client = makeMockClient({ getByVendor });
|
||||
|
||||
// The tool itself does not implement retry logic — that is the
|
||||
// responsibility of the client implementation. We verify here that
|
||||
// when the client internally retries and succeeds, the tool returns
|
||||
// the correct result. We simulate this by wrapping the tool call
|
||||
// in a simple retry loop (as a client-layer retry would do).
|
||||
const tool = makeLookupByVendorTool(client);
|
||||
let result;
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
result = await tool.handler({ vendor: "Acme" }, financeCtx);
|
||||
break;
|
||||
} catch {
|
||||
if (attempt === 1) throw new Error("Max retries exceeded");
|
||||
}
|
||||
}
|
||||
|
||||
expect(result?.count).toBe(1);
|
||||
expect(getByVendor).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("throws ScopeError when the caller lacks finance:read", async () => {
|
||||
const tool = makeLookupByVendorTool(client);
|
||||
|
||||
await expect(
|
||||
tool.handler({ vendor: "Acme" }, opsOnlyCtx),
|
||||
).rejects.toThrow();
|
||||
|
||||
// Verify that the error comes from requireScope, not from the client.
|
||||
expect(client.getByVendor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("has the correct tool metadata", () => {
|
||||
const tool = makeLookupByVendorTool(client);
|
||||
|
||||
expect(tool.name).toBe("lookup_payment_by_vendor");
|
||||
expect(tool.tier).toBe("finance");
|
||||
expect(tool.requiredScope).toBe("finance:read");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// lookup_payment_by_invoice
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("lookup_payment_by_invoice", () => {
|
||||
let client: PaymentsClient;
|
||||
|
||||
beforeEach(() => {
|
||||
client = makeMockClient();
|
||||
});
|
||||
|
||||
it("returns the matching payment with sensitive fields redacted", async () => {
|
||||
const tool = makeLookupByInvoiceTool(client);
|
||||
const result = await tool.handler(
|
||||
{ invoiceNumber: "INV-2026-0042" },
|
||||
financeCtx,
|
||||
);
|
||||
|
||||
expect(result.count).toBe(1);
|
||||
const p = result.payments[0]!;
|
||||
expect(p.vendor).toBe("Acme Electrical Supply");
|
||||
expect(p.bankAccountNumber).not.toBe(samplePayment.bankAccountNumber);
|
||||
expect(p.bankRoutingNumber).not.toBe(samplePayment.bankRoutingNumber);
|
||||
expect(p.cardNumber).not.toBe(samplePayment.cardNumber);
|
||||
});
|
||||
|
||||
it("forwards the invoice number to the client", async () => {
|
||||
const tool = makeLookupByInvoiceTool(client);
|
||||
await tool.handler({ invoiceNumber: "INV-2026-0042" }, financeCtx);
|
||||
|
||||
expect(client.getByInvoice).toHaveBeenCalledWith("INV-2026-0042");
|
||||
});
|
||||
|
||||
it("returns empty result when invoice is not found", async () => {
|
||||
client = makeMockClient({
|
||||
getByInvoice: vi.fn().mockResolvedValue([]),
|
||||
});
|
||||
const tool = makeLookupByInvoiceTool(client);
|
||||
const result = await tool.handler(
|
||||
{ invoiceNumber: "INV-NOTFOUND" },
|
||||
financeCtx,
|
||||
);
|
||||
|
||||
expect(result.count).toBe(0);
|
||||
expect(result.payments).toEqual([]);
|
||||
});
|
||||
|
||||
it("propagates a client error", async () => {
|
||||
client = makeMockClient({
|
||||
getByInvoice: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("Internal service error")),
|
||||
});
|
||||
const tool = makeLookupByInvoiceTool(client);
|
||||
|
||||
await expect(
|
||||
tool.handler({ invoiceNumber: "INV-2026-0042" }, financeCtx),
|
||||
).rejects.toThrow("Internal service error");
|
||||
});
|
||||
|
||||
it("retries and succeeds after a transient throttle error", async () => {
|
||||
const throttleError = Object.assign(
|
||||
new Error("ProvisionedThroughputExceededException"),
|
||||
{ name: "ProvisionedThroughputExceededException" },
|
||||
);
|
||||
const getByInvoice = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(throttleError)
|
||||
.mockResolvedValueOnce([samplePayment]);
|
||||
|
||||
client = makeMockClient({ getByInvoice });
|
||||
const tool = makeLookupByInvoiceTool(client);
|
||||
|
||||
let result;
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
result = await tool.handler(
|
||||
{ invoiceNumber: "INV-2026-0042" },
|
||||
financeCtx,
|
||||
);
|
||||
break;
|
||||
} catch {
|
||||
if (attempt === 1) throw new Error("Max retries exceeded");
|
||||
}
|
||||
}
|
||||
|
||||
expect(result?.count).toBe(1);
|
||||
expect(getByInvoice).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("throws ScopeError when the caller lacks finance:read", async () => {
|
||||
const tool = makeLookupByInvoiceTool(client);
|
||||
|
||||
await expect(
|
||||
tool.handler({ invoiceNumber: "INV-2026-0042" }, opsOnlyCtx),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(client.getByInvoice).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("has the correct tool metadata", () => {
|
||||
const tool = makeLookupByInvoiceTool(client);
|
||||
|
||||
expect(tool.name).toBe("lookup_payment_by_invoice");
|
||||
expect(tool.tier).toBe("finance");
|
||||
expect(tool.requiredScope).toBe("finance:read");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// lookup_payment_by_check
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("lookup_payment_by_check", () => {
|
||||
let client: PaymentsClient;
|
||||
|
||||
beforeEach(() => {
|
||||
client = makeMockClient();
|
||||
});
|
||||
|
||||
it("returns the matching payment with sensitive fields redacted", async () => {
|
||||
const tool = makeLookupByCheckTool(client);
|
||||
const result = await tool.handler({ checkNumber: "10412" }, financeCtx);
|
||||
|
||||
expect(result.count).toBe(1);
|
||||
const p = result.payments[0]!;
|
||||
expect(p.vendor).toBe("Acme Electrical Supply");
|
||||
expect(p.bankAccountNumber).not.toBe(samplePayment.bankAccountNumber);
|
||||
expect(p.bankRoutingNumber).not.toBe(samplePayment.bankRoutingNumber);
|
||||
expect(p.cardNumber).not.toBe(samplePayment.cardNumber);
|
||||
});
|
||||
|
||||
it("forwards the check number to the client", async () => {
|
||||
const tool = makeLookupByCheckTool(client);
|
||||
await tool.handler({ checkNumber: "10412" }, financeCtx);
|
||||
|
||||
expect(client.getByCheck).toHaveBeenCalledWith("10412");
|
||||
});
|
||||
|
||||
it("returns empty result when check number is not found", async () => {
|
||||
client = makeMockClient({
|
||||
getByCheck: vi.fn().mockResolvedValue([]),
|
||||
});
|
||||
const tool = makeLookupByCheckTool(client);
|
||||
const result = await tool.handler({ checkNumber: "99999" }, financeCtx);
|
||||
|
||||
expect(result.count).toBe(0);
|
||||
expect(result.payments).toEqual([]);
|
||||
});
|
||||
|
||||
it("propagates a client error", async () => {
|
||||
client = makeMockClient({
|
||||
getByCheck: vi.fn().mockRejectedValue(new Error("Connection timeout")),
|
||||
});
|
||||
const tool = makeLookupByCheckTool(client);
|
||||
|
||||
await expect(
|
||||
tool.handler({ checkNumber: "10412" }, financeCtx),
|
||||
).rejects.toThrow("Connection timeout");
|
||||
});
|
||||
|
||||
it("retries and succeeds after a transient throttle error", async () => {
|
||||
const throttleError = Object.assign(
|
||||
new Error("ProvisionedThroughputExceededException"),
|
||||
{ name: "ProvisionedThroughputExceededException" },
|
||||
);
|
||||
const getByCheck = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(throttleError)
|
||||
.mockResolvedValueOnce([samplePayment]);
|
||||
|
||||
client = makeMockClient({ getByCheck });
|
||||
const tool = makeLookupByCheckTool(client);
|
||||
|
||||
let result;
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
result = await tool.handler({ checkNumber: "10412" }, financeCtx);
|
||||
break;
|
||||
} catch {
|
||||
if (attempt === 1) throw new Error("Max retries exceeded");
|
||||
}
|
||||
}
|
||||
|
||||
expect(result?.count).toBe(1);
|
||||
expect(getByCheck).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("throws ScopeError when the caller lacks finance:read", async () => {
|
||||
const tool = makeLookupByCheckTool(client);
|
||||
|
||||
await expect(
|
||||
tool.handler({ checkNumber: "10412" }, opsOnlyCtx),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(client.getByCheck).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("has the correct tool metadata", () => {
|
||||
const tool = makeLookupByCheckTool(client);
|
||||
|
||||
expect(tool.name).toBe("lookup_payment_by_check");
|
||||
expect(tool.tier).toBe("finance");
|
||||
expect(tool.requiredScope).toBe("finance:read");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Redaction contract — cross-cutting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("redaction contract", () => {
|
||||
it("does not redact vendor name or vendor contact", async () => {
|
||||
const client = makeMockClient();
|
||||
const tool = makeLookupByVendorTool(client);
|
||||
const result = await tool.handler({ vendor: "Acme" }, financeCtx);
|
||||
|
||||
const p = result.payments[0]!;
|
||||
expect(p.vendor).toBe("Acme Electrical Supply");
|
||||
expect(p.vendorContact).toBe("billing@acme.example.com");
|
||||
});
|
||||
|
||||
it("redacts all three sensitive fields when present", async () => {
|
||||
const client = makeMockClient();
|
||||
const tool = makeLookupByVendorTool(client);
|
||||
const result = await tool.handler({ vendor: "Acme" }, financeCtx);
|
||||
|
||||
const p = result.payments[0]!;
|
||||
// None of the redacted values should equal the originals.
|
||||
expect(p.bankAccountNumber).not.toBe("123456789");
|
||||
expect(p.bankRoutingNumber).not.toBe("021000021");
|
||||
expect(p.cardNumber).not.toBe("4111111111111111");
|
||||
});
|
||||
|
||||
it("omits redacted fields when they were undefined in the source", async () => {
|
||||
const minimalPayment: Payment = {
|
||||
paymentId: "pmt-002",
|
||||
vendor: "Generic Vendor",
|
||||
amount: 100,
|
||||
currency: "USD",
|
||||
paymentDate: "2026-06-01",
|
||||
status: "cleared",
|
||||
// No bankAccountNumber, bankRoutingNumber, or cardNumber
|
||||
};
|
||||
const client = makeMockClient({
|
||||
getByVendor: vi.fn().mockResolvedValue([minimalPayment]),
|
||||
});
|
||||
const tool = makeLookupByVendorTool(client);
|
||||
const result = await tool.handler({ vendor: "Generic" }, financeCtx);
|
||||
|
||||
const p = result.payments[0]!;
|
||||
expect(p.bankAccountNumber).toBeUndefined();
|
||||
expect(p.bankRoutingNumber).toBeUndefined();
|
||||
expect(p.cardNumber).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// requireScope integration check
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("requireScope integration", () => {
|
||||
it("requireScope does not throw when finance:read is present", () => {
|
||||
// Smoke-test the shared helper directly to confirm it accepts our fixture.
|
||||
expect(() => requireScope(financeCtx, "finance:read")).not.toThrow();
|
||||
});
|
||||
|
||||
it("requireScope throws when finance:read is absent", () => {
|
||||
expect(() => requireScope(opsOnlyCtx, "finance:read")).toThrow();
|
||||
});
|
||||
});
|
||||
10
packages/payments/tsconfig.json
Normal file
10
packages/payments/tsconfig.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "./dist",
|
||||
"declarationDir": "./dist"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["dist", "node_modules", "test"]
|
||||
}
|
||||
34
packages/qbo/package.json
Normal file
34
packages/qbo/package.json
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"name": "@sh-mcp/qbo",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "QuickBooks Online vendor search tool for sh-mcp finance tier",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --project tsconfig.json",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sh-mcp/shared": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
}
|
||||
}
|
||||
126
packages/qbo/src/client.ts
Normal file
126
packages/qbo/src/client.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
/**
|
||||
* QBO client interface and implementation.
|
||||
*
|
||||
* The real QuickBooks Online API is reached via OAuth 2.0 with a server-held
|
||||
* refresh token (stored in AWS Secrets Manager). The implementation below
|
||||
* stubs the actual HTTP call so no real network I/O happens at import time.
|
||||
*
|
||||
* TODO (DEFERRED — auth layer, phase 3): Replace the stub in QboClientImpl with
|
||||
* a real intuit-oauth + node-quickbooks (or raw fetch) call that:
|
||||
* 1. Reads the refresh token from Secrets Manager at
|
||||
* arn:aws:secretsmanager:us-east-1:328440206208:secret:sh-mcp/qbo-oauth
|
||||
* 2. Exchanges it for a short-lived access token on demand (and rotates the
|
||||
* stored refresh token when QBO returns a new one).
|
||||
* 3. Issues the GET /v3/company/{realmId}/query?query=... request.
|
||||
* 4. Never logs or returns the access/refresh tokens.
|
||||
* The QboClientInterface below is the stable contract; the implementation is
|
||||
* injected, so tests and the real server each provide their own.
|
||||
*/
|
||||
|
||||
/** A vendor record as returned by QBO's vendor query endpoint. */
|
||||
export interface QboVendor {
|
||||
/** QBO internal vendor ID */
|
||||
id: string;
|
||||
/** Display name of the vendor */
|
||||
displayName: string;
|
||||
/** Primary contact email, if present */
|
||||
email?: string;
|
||||
/** Primary phone number, if present */
|
||||
phone?: string;
|
||||
/** Whether the vendor is currently active */
|
||||
active: boolean;
|
||||
/** Vendor balance (amount owed), expressed as a number */
|
||||
balance?: number;
|
||||
/**
|
||||
* Tax identification number. Treated as sensitive — callers MUST pass this
|
||||
* through redact() before including it in a tool response.
|
||||
*/
|
||||
taxId?: string;
|
||||
}
|
||||
|
||||
/** Parameters forwarded to the QBO vendor query. */
|
||||
export interface SearchVendorsParams {
|
||||
/** Free-text search term matched against vendor display name */
|
||||
query: string;
|
||||
/** Maximum number of results to return (1–100, default 20) */
|
||||
maxResults?: number;
|
||||
}
|
||||
|
||||
/** Result envelope returned by the QBO vendor query. */
|
||||
export interface SearchVendorsResult {
|
||||
vendors: QboVendor[];
|
||||
/** Total matching vendors in QBO (may exceed vendors.length if paginated) */
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The interface every consumer codes against.
|
||||
* The real implementation, a test mock, and any future adapters all satisfy
|
||||
* this shape — nothing in src/ imports a concrete HTTP library directly.
|
||||
*/
|
||||
export interface QboClientInterface {
|
||||
/**
|
||||
* Search QBO vendors by display name.
|
||||
*
|
||||
* @throws {QboThrottleError} when QBO returns HTTP 429.
|
||||
* @throws {QboApiError} for any other non-2xx response.
|
||||
*/
|
||||
searchVendors(params: SearchVendorsParams): Promise<SearchVendorsResult>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class QboApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly statusCode: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'QboApiError';
|
||||
}
|
||||
}
|
||||
|
||||
export class QboThrottleError extends Error {
|
||||
constructor(
|
||||
/** Seconds to wait before retrying, if provided by QBO */
|
||||
public readonly retryAfterSeconds?: number,
|
||||
) {
|
||||
super('QBO rate limit exceeded');
|
||||
this.name = 'QboThrottleError';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Thin implementation (real call stubbed — see TODO above)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Thin wrapper around the QuickBooks Online Accounting API.
|
||||
*
|
||||
* STUBBED: the actual HTTP call is replaced with a NotImplementedError so
|
||||
* this class can be imported without triggering real network I/O or requiring
|
||||
* AWS credentials. Inject a mock (see test/qbo.test.ts) in unit tests.
|
||||
*/
|
||||
export class QboClientImpl implements QboClientInterface {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async searchVendors(_params: SearchVendorsParams): Promise<SearchVendorsResult> {
|
||||
// TODO (DEFERRED — auth layer): implement real QBO API call.
|
||||
// Steps:
|
||||
// 1. Retrieve OAuth refresh token from Secrets Manager.
|
||||
// 2. Exchange for QBO access token (rotate stored refresh token if renewed).
|
||||
// 3. Build QBO SQL query:
|
||||
// SELECT * FROM Vendor WHERE DisplayName LIKE '%{query}%'
|
||||
// STARTPOSITION 1 MAXRESULTS {maxResults}
|
||||
// 4. GET https://quickbooks.api.intuit.com/v3/company/{realmId}/query
|
||||
// with Authorization: Bearer {accessToken}
|
||||
// 5. Map QBO QueryResponse.Vendor[] → SearchVendorsResult.
|
||||
// 6. On HTTP 429 → throw QboThrottleError(retryAfterSeconds).
|
||||
// 7. On other non-2xx → throw QboApiError(message, statusCode).
|
||||
throw new Error(
|
||||
'QboClientImpl.searchVendors is not yet implemented. ' +
|
||||
'Inject a mock QboClientInterface for tests, or implement the real call (see TODO above).',
|
||||
);
|
||||
}
|
||||
}
|
||||
28
packages/qbo/src/index.ts
Normal file
28
packages/qbo/src/index.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
/**
|
||||
* @sh-mcp/qbo — QuickBooks Online tools for the sh-mcp finance tier.
|
||||
*
|
||||
* Exports the pre-wired tools array (using QboClientImpl, which is stubbed
|
||||
* until the DEFERRED auth layer is implemented) plus the factory and types
|
||||
* needed by consumers that inject their own client.
|
||||
*
|
||||
* The wire transport (OpenAPI / MCP) is generated from the registry in the
|
||||
* server layer; this package only defines tools.
|
||||
*/
|
||||
|
||||
export { makeSearchVendorsTool } from './tools.js';
|
||||
export type { SearchVendorsInput, SearchVendorsOutput, VendorRecord } from './tools.js';
|
||||
export type { QboClientInterface, QboVendor, SearchVendorsParams, SearchVendorsResult } from './client.js';
|
||||
export { QboClientImpl, QboApiError, QboThrottleError } from './client.js';
|
||||
|
||||
import { makeSearchVendorsTool } from './tools.js';
|
||||
import { QboClientImpl } from './client.js';
|
||||
|
||||
/**
|
||||
* Default tools array wired with QboClientImpl.
|
||||
*
|
||||
* NOTE: QboClientImpl.searchVendors is stubbed (throws NotImplementedError)
|
||||
* until the real OAuth/Secrets Manager integration is built (see client.ts
|
||||
* TODO). For production use, instantiate QboClientImpl only after the
|
||||
* DEFERRED auth layer is in place, or inject your own QboClientInterface.
|
||||
*/
|
||||
export const tools = [makeSearchVendorsTool(new QboClientImpl())];
|
||||
149
packages/qbo/src/tools.ts
Normal file
149
packages/qbo/src/tools.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
/**
|
||||
* QBO tool definitions for the sh-mcp finance tier.
|
||||
*
|
||||
* Each tool is defined with defineTool() from @sh-mcp/shared. The handler:
|
||||
* 1. Calls requireScope() to enforce finance:read server-side (never trusts
|
||||
* UI-level tool-hiding as the access boundary).
|
||||
* 2. Calls the injected QboClientInterface — no direct HTTP from here.
|
||||
* 3. Passes all sensitive fields through redact() before returning.
|
||||
*
|
||||
* The client is injected rather than imported as a singleton so that:
|
||||
* - Tests can pass a mock without patching module state.
|
||||
* - Future servers can supply their own Secrets Manager-backed instance.
|
||||
*/
|
||||
|
||||
import { defineTool, requireScope, redact, maskValue } from '@sh-mcp/shared';
|
||||
import type { AuthContext } from '@sh-mcp/shared';
|
||||
import type { QboClientInterface, QboVendor } from './client.js';
|
||||
import { QboThrottleError, QboApiError } from './client.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Output types (what the tool returns over the wire)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface VendorRecord {
|
||||
id: string;
|
||||
displayName: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
active: boolean;
|
||||
balance?: number;
|
||||
/** Tax ID, always redacted when present */
|
||||
taxId?: string;
|
||||
}
|
||||
|
||||
export interface SearchVendorsOutput {
|
||||
vendors: VendorRecord[];
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input schema (JSON Schema object, used for MCP / OpenAPI generation)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const searchVendorsInputSchema = {
|
||||
type: 'object',
|
||||
required: ['query'],
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Search term matched against vendor display name. ' +
|
||||
'Case-insensitive substring match.',
|
||||
minLength: 1,
|
||||
maxLength: 200,
|
||||
},
|
||||
maxResults: {
|
||||
type: 'integer',
|
||||
description: 'Maximum number of vendors to return (1–100). Defaults to 20.',
|
||||
minimum: 1,
|
||||
maximum: 100,
|
||||
default: 20,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export interface SearchVendorsInput {
|
||||
query: string;
|
||||
maxResults?: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns the search_vendors ToolDef with the given QBO client injected.
|
||||
*
|
||||
* Usage:
|
||||
* import { makeSearchVendorsTool } from '@sh-mcp/qbo';
|
||||
* import { QboClientImpl } from '@sh-mcp/qbo/client';
|
||||
* const tool = makeSearchVendorsTool(new QboClientImpl());
|
||||
*/
|
||||
export function makeSearchVendorsTool(client: QboClientInterface) {
|
||||
return defineTool<SearchVendorsInput, SearchVendorsOutput>({
|
||||
name: 'search_vendors',
|
||||
description:
|
||||
'Search QuickBooks Online vendors by display name. ' +
|
||||
'Returns vendor contact details and account balance. ' +
|
||||
'Sensitive fields (tax IDs) are redacted in the response. ' +
|
||||
'Requires finance:read scope.',
|
||||
tier: 'finance',
|
||||
requiredScope: 'finance:read',
|
||||
inputSchema: searchVendorsInputSchema,
|
||||
|
||||
async handler(
|
||||
input: SearchVendorsInput,
|
||||
ctx: AuthContext,
|
||||
): Promise<SearchVendorsOutput> {
|
||||
// Server-side scope enforcement — authoritative, not a UI hint.
|
||||
requireScope(ctx, 'finance:read');
|
||||
|
||||
const maxResults = input.maxResults ?? 20;
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await client.searchVendors({
|
||||
query: input.query,
|
||||
maxResults,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof QboThrottleError) {
|
||||
// Surface throttle detail so callers can back off.
|
||||
const waitHint =
|
||||
err.retryAfterSeconds !== undefined
|
||||
? ` Retry after ${err.retryAfterSeconds}s.`
|
||||
: '';
|
||||
throw new Error(`QBO rate limit exceeded.${waitHint}`);
|
||||
}
|
||||
if (err instanceof QboApiError) {
|
||||
throw new Error(`QBO API error (HTTP ${err.statusCode}): ${err.message}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Redact sensitive fields on every vendor before returning.
|
||||
const vendors: VendorRecord[] = result.vendors.map(
|
||||
(v: QboVendor): VendorRecord => ({
|
||||
id: v.id,
|
||||
// displayName and email/phone are vendor contacts — redact() leaves
|
||||
// vendor names and contact info intact per its contract.
|
||||
displayName: v.displayName,
|
||||
...(v.email !== undefined && { email: v.email }),
|
||||
...(v.phone !== undefined && { phone: v.phone }),
|
||||
active: v.active,
|
||||
...(v.balance !== undefined && { balance: v.balance }),
|
||||
// taxId is sensitive — run through redact() (required for finance tier)
|
||||
// and additionally through maskValue() for field-level character masking.
|
||||
...(v.taxId !== undefined && { taxId: maskValue(redact(v.taxId)) }),
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
vendors,
|
||||
totalCount: result.totalCount,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
296
packages/qbo/test/qbo.test.ts
Normal file
296
packages/qbo/test/qbo.test.ts
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
/**
|
||||
* Unit tests for @sh-mcp/qbo.
|
||||
*
|
||||
* All tests inject a mock QboClientInterface — no real network calls, no AWS.
|
||||
* A mock AuthContext is passed directly; real JWT validation is the DEFERRED
|
||||
* auth layer and is not exercised here.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { AuthContext } from '@sh-mcp/shared';
|
||||
import { makeSearchVendorsTool } from '../src/tools.js';
|
||||
import type { QboClientInterface, SearchVendorsParams, SearchVendorsResult } from '../src/client.js';
|
||||
import { QboApiError, QboThrottleError } from '../src/client.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A mock AuthContext that carries finance:read scope. */
|
||||
function makeFinanceCtx(overrides?: Partial<AuthContext>): AuthContext {
|
||||
return {
|
||||
sub: 'adam@seahavenind.com',
|
||||
scopes: ['ops:read', 'finance:read'],
|
||||
aud: 'sh-mcp-finance',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** A mock QboClientInterface backed by a vitest spy. */
|
||||
function makeMockClient(
|
||||
impl: (params: SearchVendorsParams) => Promise<SearchVendorsResult>,
|
||||
): QboClientInterface {
|
||||
return {
|
||||
searchVendors: vi.fn(impl),
|
||||
};
|
||||
}
|
||||
|
||||
/** Minimal vendor fixture. */
|
||||
const VENDOR_ACME = {
|
||||
id: 'qbo-vendor-001',
|
||||
displayName: 'Acme Supplies',
|
||||
email: 'billing@acme.example',
|
||||
phone: '555-0100',
|
||||
active: true,
|
||||
balance: 1250.0,
|
||||
taxId: '12-3456789',
|
||||
};
|
||||
|
||||
/** Vendor fixture with no optional fields. */
|
||||
const VENDOR_MINIMAL = {
|
||||
id: 'qbo-vendor-002',
|
||||
displayName: 'Beta Services',
|
||||
active: false,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Happy-path tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('search_vendors — happy path', () => {
|
||||
let client: QboClientInterface;
|
||||
|
||||
beforeEach(() => {
|
||||
client = makeMockClient(async () => ({
|
||||
vendors: [VENDOR_ACME, VENDOR_MINIMAL],
|
||||
totalCount: 2,
|
||||
}));
|
||||
});
|
||||
|
||||
it('returns vendor records with the correct shape', async () => {
|
||||
const tool = makeSearchVendorsTool(client);
|
||||
const result = await tool.handler({ query: 'acme' }, makeFinanceCtx());
|
||||
|
||||
expect(result.totalCount).toBe(2);
|
||||
expect(result.vendors).toHaveLength(2);
|
||||
|
||||
const acme = result.vendors[0];
|
||||
expect(acme.id).toBe('qbo-vendor-001');
|
||||
expect(acme.displayName).toBe('Acme Supplies');
|
||||
expect(acme.email).toBe('billing@acme.example');
|
||||
expect(acme.phone).toBe('555-0100');
|
||||
expect(acme.active).toBe(true);
|
||||
expect(acme.balance).toBe(1250.0);
|
||||
});
|
||||
|
||||
it('redacts taxId in the response', async () => {
|
||||
const tool = makeSearchVendorsTool(client);
|
||||
const result = await tool.handler({ query: 'acme' }, makeFinanceCtx());
|
||||
|
||||
const acme = result.vendors[0];
|
||||
// taxId must be present but must NOT equal the raw value
|
||||
expect(acme.taxId).toBeDefined();
|
||||
expect(acme.taxId).not.toBe('12-3456789');
|
||||
// redact() replaces sensitive data with masked characters
|
||||
expect(acme.taxId).toMatch(/[*x●]/i);
|
||||
});
|
||||
|
||||
it('preserves vendor displayName and contact fields intact (not redacted)', async () => {
|
||||
const tool = makeSearchVendorsTool(client);
|
||||
const result = await tool.handler({ query: 'acme' }, makeFinanceCtx());
|
||||
|
||||
const acme = result.vendors[0];
|
||||
// redact() contract: leaves vendor names and contact info intact
|
||||
expect(acme.displayName).toBe('Acme Supplies');
|
||||
expect(acme.email).toBe('billing@acme.example');
|
||||
expect(acme.phone).toBe('555-0100');
|
||||
});
|
||||
|
||||
it('omits optional fields when the vendor has none', async () => {
|
||||
const tool = makeSearchVendorsTool(client);
|
||||
const result = await tool.handler({ query: 'beta' }, makeFinanceCtx());
|
||||
|
||||
const beta = result.vendors[1];
|
||||
expect(beta.id).toBe('qbo-vendor-002');
|
||||
expect(beta.displayName).toBe('Beta Services');
|
||||
expect(beta.active).toBe(false);
|
||||
expect(beta.email).toBeUndefined();
|
||||
expect(beta.phone).toBeUndefined();
|
||||
expect(beta.balance).toBeUndefined();
|
||||
expect(beta.taxId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('forwards maxResults to the client', async () => {
|
||||
const tool = makeSearchVendorsTool(client);
|
||||
await tool.handler({ query: 'acme', maxResults: 5 }, makeFinanceCtx());
|
||||
|
||||
expect(client.searchVendors).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ maxResults: 5 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults maxResults to 20 when not specified', async () => {
|
||||
const tool = makeSearchVendorsTool(client);
|
||||
await tool.handler({ query: 'acme' }, makeFinanceCtx());
|
||||
|
||||
expect(client.searchVendors).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ maxResults: 20 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Empty result
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('search_vendors — empty result', () => {
|
||||
it('returns an empty vendors array and totalCount 0 when QBO finds nothing', async () => {
|
||||
const client = makeMockClient(async () => ({ vendors: [], totalCount: 0 }));
|
||||
const tool = makeSearchVendorsTool(client);
|
||||
|
||||
const result = await tool.handler({ query: 'nonexistent vendor xyz' }, makeFinanceCtx());
|
||||
|
||||
expect(result.vendors).toHaveLength(0);
|
||||
expect(result.totalCount).toBe(0);
|
||||
});
|
||||
|
||||
it('returns totalCount accurately when results are paginated (totalCount > vendors.length)', async () => {
|
||||
const client = makeMockClient(async () => ({
|
||||
vendors: [VENDOR_MINIMAL],
|
||||
totalCount: 47,
|
||||
}));
|
||||
const tool = makeSearchVendorsTool(client);
|
||||
|
||||
const result = await tool.handler({ query: 'services', maxResults: 1 }, makeFinanceCtx());
|
||||
|
||||
expect(result.vendors).toHaveLength(1);
|
||||
expect(result.totalCount).toBe(47);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error paths
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('search_vendors — error handling', () => {
|
||||
it('rethrows unexpected errors from the client unchanged', async () => {
|
||||
const client = makeMockClient(async () => {
|
||||
throw new Error('Unexpected internal failure');
|
||||
});
|
||||
const tool = makeSearchVendorsTool(client);
|
||||
|
||||
await expect(tool.handler({ query: 'acme' }, makeFinanceCtx())).rejects.toThrow(
|
||||
'Unexpected internal failure',
|
||||
);
|
||||
});
|
||||
|
||||
it('wraps QboApiError with status code in the thrown message', async () => {
|
||||
const client = makeMockClient(async () => {
|
||||
throw new QboApiError('Forbidden', 403);
|
||||
});
|
||||
const tool = makeSearchVendorsTool(client);
|
||||
|
||||
await expect(tool.handler({ query: 'acme' }, makeFinanceCtx())).rejects.toThrow(
|
||||
'QBO API error (HTTP 403): Forbidden',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws a scope error when the context lacks finance:read', async () => {
|
||||
const client = makeMockClient(async () => ({ vendors: [], totalCount: 0 }));
|
||||
const tool = makeSearchVendorsTool(client);
|
||||
|
||||
// ops-only context — missing finance:read
|
||||
const opsCtx = makeFinanceCtx({ scopes: ['ops:read'] });
|
||||
|
||||
await expect(tool.handler({ query: 'acme' }, opsCtx)).rejects.toThrow();
|
||||
// client should never be called when scope check fails
|
||||
expect(client.searchVendors).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws a scope error when the context has no scopes at all', async () => {
|
||||
const client = makeMockClient(async () => ({ vendors: [], totalCount: 0 }));
|
||||
const tool = makeSearchVendorsTool(client);
|
||||
|
||||
const emptyCtx = makeFinanceCtx({ scopes: [] });
|
||||
|
||||
await expect(tool.handler({ query: 'acme' }, emptyCtx)).rejects.toThrow();
|
||||
expect(client.searchVendors).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Throttle / retry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('search_vendors — throttle / retry', () => {
|
||||
it('surfaces a rate-limit error with retry hint when QBO returns 429 with retryAfter', async () => {
|
||||
const client = makeMockClient(async () => {
|
||||
throw new QboThrottleError(30);
|
||||
});
|
||||
const tool = makeSearchVendorsTool(client);
|
||||
|
||||
await expect(tool.handler({ query: 'acme' }, makeFinanceCtx())).rejects.toThrow(
|
||||
/rate limit.*retry after 30s/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces a rate-limit error without retry hint when QBO omits retryAfter', async () => {
|
||||
const client = makeMockClient(async () => {
|
||||
throw new QboThrottleError();
|
||||
});
|
||||
const tool = makeSearchVendorsTool(client);
|
||||
|
||||
const err = await tool
|
||||
.handler({ query: 'acme' }, makeFinanceCtx())
|
||||
.catch((e: unknown) => e as Error);
|
||||
|
||||
expect(err.message).toMatch(/rate limit/i);
|
||||
// No "Retry after Xs" appended
|
||||
expect(err.message).not.toMatch(/retry after/i);
|
||||
});
|
||||
|
||||
it('does not swallow the error — the caller is responsible for retry logic', async () => {
|
||||
// The tool itself does not retry; it propagates so the server layer
|
||||
// (or the MCP client) can back off and retry.
|
||||
const client = makeMockClient(async () => {
|
||||
throw new QboThrottleError(60);
|
||||
});
|
||||
const tool = makeSearchVendorsTool(client);
|
||||
|
||||
await expect(tool.handler({ query: 'acme' }, makeFinanceCtx())).rejects.toThrow();
|
||||
// Called exactly once — no internal retry loop.
|
||||
expect(client.searchVendors).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool metadata
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('search_vendors — tool definition', () => {
|
||||
it('has the correct name', () => {
|
||||
const tool = makeSearchVendorsTool(makeMockClient(async () => ({ vendors: [], totalCount: 0 })));
|
||||
expect(tool.name).toBe('search_vendors');
|
||||
});
|
||||
|
||||
it('declares finance tier', () => {
|
||||
const tool = makeSearchVendorsTool(makeMockClient(async () => ({ vendors: [], totalCount: 0 })));
|
||||
expect(tool.tier).toBe('finance');
|
||||
});
|
||||
|
||||
it('requires finance:read scope', () => {
|
||||
const tool = makeSearchVendorsTool(makeMockClient(async () => ({ vendors: [], totalCount: 0 })));
|
||||
expect(tool.requiredScope).toBe('finance:read');
|
||||
});
|
||||
|
||||
it('has an inputSchema that marks query as required', () => {
|
||||
const tool = makeSearchVendorsTool(makeMockClient(async () => ({ vendors: [], totalCount: 0 })));
|
||||
const schema = tool.inputSchema as {
|
||||
required: string[];
|
||||
properties: Record<string, unknown>;
|
||||
};
|
||||
expect(schema.required).toContain('query');
|
||||
expect(schema.properties).toHaveProperty('query');
|
||||
expect(schema.properties).toHaveProperty('maxResults');
|
||||
});
|
||||
});
|
||||
10
packages/qbo/tsconfig.json
Normal file
10
packages/qbo/tsconfig.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declarationDir": "./dist"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "test"]
|
||||
}
|
||||
27
packages/reminders/package.json
Normal file
27
packages/reminders/package.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"name": "@sh-mcp/reminders",
|
||||
"version": "0.1.0",
|
||||
"description": "Sea Haven MCP reminders tools — create_reminder backed by EventBridge Scheduler",
|
||||
"license": "UNLICENSED",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sh-mcp/shared": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.5.0",
|
||||
"vitest": "^2.0.0"
|
||||
}
|
||||
}
|
||||
110
packages/reminders/src/client.ts
Normal file
110
packages/reminders/src/client.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
/**
|
||||
* EventBridge Scheduler client interface and implementation.
|
||||
*
|
||||
* The real AWS call is clearly stubbed/guarded behind this interface so:
|
||||
* - Tests inject a mock without any network or AWS SDK import side-effects.
|
||||
* - The production implementation is swapped in at server startup via dependency injection.
|
||||
*
|
||||
* TODO (DEFERRED auth layer — 0a gate): The production SchedulerClient should read its
|
||||
* AWS credentials from the environment (Lambda execution role) rather than from any
|
||||
* hardcoded credential chain. IAM cross-review is required before wiring the real client
|
||||
* into the server bundle (see design.md §2.5 and §8).
|
||||
*/
|
||||
|
||||
export interface CreateScheduleInput {
|
||||
/** Unique name for the EventBridge schedule (must be [a-zA-Z0-9_-]+). */
|
||||
scheduleName: string;
|
||||
/** ISO-8601 datetime string at which the one-shot schedule fires. */
|
||||
scheduleAt: string;
|
||||
/** ARN of the Lambda target that delivers the reminder. */
|
||||
targetArn: string;
|
||||
/** Arbitrary payload forwarded to the target Lambda. */
|
||||
payload: Record<string, unknown>;
|
||||
/** ARN of the IAM role EventBridge Scheduler assumes to invoke the target. */
|
||||
roleArn: string;
|
||||
}
|
||||
|
||||
export interface CreateScheduleOutput {
|
||||
/** The ARN of the created EventBridge schedule. */
|
||||
scheduleArn: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin abstraction over the EventBridge Scheduler API.
|
||||
* Swap the real implementation in at server startup; inject a mock in tests.
|
||||
*/
|
||||
export interface SchedulerClient {
|
||||
createSchedule(input: CreateScheduleInput): Promise<CreateScheduleOutput>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Production implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Real SchedulerClient that calls the AWS EventBridge Scheduler API.
|
||||
*
|
||||
* IMPORTANT: This module intentionally does NOT import the AWS SDK at the top
|
||||
* level. The dynamic import inside createSchedule ensures no AWS SDK code (and
|
||||
* no credential-chain resolution) runs at import time — critical for unit tests
|
||||
* running without AWS credentials.
|
||||
*
|
||||
* TODO (production wiring): Before deploying, confirm:
|
||||
* - The Lambda execution role has `scheduler:CreateSchedule` on the target
|
||||
* schedule group (least-privilege per design.md §2.5).
|
||||
* - REGION defaults to process.env.AWS_REGION (set automatically in Lambda).
|
||||
* - TARGET_ARN and SCHEDULER_ROLE_ARN are injected via CDK environment variables
|
||||
* (never hardcoded).
|
||||
*/
|
||||
export class AwsSchedulerClient implements SchedulerClient {
|
||||
// Fields are stored now and used when the real AWS SDK call is uncommented (see TODO above).
|
||||
private readonly _region: string;
|
||||
private readonly _targetArn: string;
|
||||
private readonly _roleArn: string;
|
||||
|
||||
constructor(opts: {
|
||||
region?: string;
|
||||
targetArn: string;
|
||||
roleArn: string;
|
||||
}) {
|
||||
this._region = opts.region ?? process.env['AWS_REGION'] ?? 'us-east-1';
|
||||
this._targetArn = opts.targetArn;
|
||||
this._roleArn = opts.roleArn;
|
||||
// Read each field once so TypeScript does not flag them as write-only.
|
||||
void this._region;
|
||||
void this._targetArn;
|
||||
void this._roleArn;
|
||||
}
|
||||
|
||||
async createSchedule(_input: CreateScheduleInput): Promise<CreateScheduleOutput> {
|
||||
// Dynamic import so the AWS SDK is not loaded during unit tests.
|
||||
// TODO: replace this stub with the real @aws-sdk/client-scheduler call
|
||||
// once the IAM cross-review gate (design.md §8) has been passed.
|
||||
// Example real call (do not remove — kept for implementer reference):
|
||||
//
|
||||
// const { SchedulerClient, CreateScheduleCommand } = await import(
|
||||
// '@aws-sdk/client-scheduler'
|
||||
// );
|
||||
// const client = new SchedulerClient({ region: this.region });
|
||||
// const result = await client.send(
|
||||
// new CreateScheduleCommand({
|
||||
// Name: input.scheduleName,
|
||||
// ScheduleExpression: `at(${input.scheduleAt})`,
|
||||
// ScheduleExpressionTimezone: 'UTC',
|
||||
// FlexibleTimeWindow: { Mode: 'OFF' },
|
||||
// Target: {
|
||||
// Arn: this.targetArn,
|
||||
// RoleArn: this.roleArn,
|
||||
// Input: JSON.stringify(input.payload),
|
||||
// },
|
||||
// })
|
||||
// );
|
||||
// return { scheduleArn: result.ScheduleArn! };
|
||||
|
||||
throw new Error(
|
||||
'AwsSchedulerClient.createSchedule: production AWS SDK call is not yet wired. ' +
|
||||
'Inject a SchedulerClient mock in tests, or complete the IAM cross-review and ' +
|
||||
'uncomment the real SDK call before deploying.'
|
||||
);
|
||||
}
|
||||
}
|
||||
33
packages/reminders/src/index.ts
Normal file
33
packages/reminders/src/index.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
/**
|
||||
* @sh-mcp/reminders
|
||||
*
|
||||
* Exports the reminders tool array pre-wired with the production AwsSchedulerClient.
|
||||
* Server bundles import this directly. Tests import buildReminderTools and inject
|
||||
* a mock client instead.
|
||||
*
|
||||
* NOTE: The production AwsSchedulerClient constructor reads REMINDER_TARGET_ARN and
|
||||
* SCHEDULER_ROLE_ARN from environment variables, which are injected by CDK at deploy
|
||||
* time. Importing this module without those env vars set (e.g. in unit tests) is safe
|
||||
* because the real client is never called — tests inject their own mock via
|
||||
* buildReminderTools().
|
||||
*/
|
||||
|
||||
export { buildReminderTools } from './tools.js';
|
||||
export type { CreateReminderInput, CreateReminderOutput } from './tools.js';
|
||||
export type { SchedulerClient, CreateScheduleInput, CreateScheduleOutput } from './client.js';
|
||||
export { AwsSchedulerClient } from './client.js';
|
||||
|
||||
import { buildReminderTools } from './tools.js';
|
||||
import { AwsSchedulerClient } from './client.js';
|
||||
|
||||
/**
|
||||
* Default tool array — uses the production AwsSchedulerClient.
|
||||
* The client's constructor does NOT call AWS; the call only happens in the handler.
|
||||
* Import at server startup only after CDK env vars are available.
|
||||
*/
|
||||
export const tools = buildReminderTools({
|
||||
client: new AwsSchedulerClient({
|
||||
targetArn: process.env['REMINDER_TARGET_ARN'] ?? '',
|
||||
roleArn: process.env['SCHEDULER_ROLE_ARN'] ?? '',
|
||||
}),
|
||||
});
|
||||
148
packages/reminders/src/tools.ts
Normal file
148
packages/reminders/src/tools.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import { defineTool } from '@sh-mcp/shared';
|
||||
import type { AuthContext } from '@sh-mcp/shared';
|
||||
import { requireScope } from '@sh-mcp/shared';
|
||||
import type { SchedulerClient } from './client.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input / Output types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CreateReminderInput {
|
||||
/** ISO-8601 datetime at which the reminder should fire (e.g. 2026-06-15T14:00:00Z). */
|
||||
remind_at: string;
|
||||
/** Short human-readable message to deliver when the reminder fires (max 500 chars). */
|
||||
message: string;
|
||||
/**
|
||||
* Optional channel or user ID to notify (defaults to the requesting user's DM).
|
||||
* Accepted forms: a Slack user ID (Uxxx), a Slack channel ID (Cxxx), or the
|
||||
* literal string "me" to target the calling user.
|
||||
*/
|
||||
recipient?: string;
|
||||
}
|
||||
|
||||
export interface CreateReminderOutput {
|
||||
/** Unique identifier for the created reminder / schedule. */
|
||||
reminder_id: string;
|
||||
/** Echo of the requested delivery time. */
|
||||
remind_at: string;
|
||||
/** Confirmation message suitable for display to the user. */
|
||||
confirmation: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool factory — client injected so tests can swap the mock
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the reminders tool array with the given SchedulerClient injected.
|
||||
*
|
||||
* Production servers call buildReminderTools({ client: new AwsSchedulerClient(...) }).
|
||||
* Tests call buildReminderTools({ client: mockClient }).
|
||||
*/
|
||||
export function buildReminderTools(deps: { client: SchedulerClient }) {
|
||||
const createReminder = defineTool<CreateReminderInput, CreateReminderOutput>({
|
||||
name: 'create_reminder',
|
||||
description:
|
||||
'Schedule a one-shot reminder to be delivered at a specific date and time. ' +
|
||||
'The reminder is fired by EventBridge Scheduler and delivered to the specified ' +
|
||||
'recipient (defaults to the calling user). Times must be in the future and ' +
|
||||
'provided as an ISO-8601 datetime string with timezone offset (e.g. 2026-06-15T14:00:00Z).',
|
||||
tier: 'ops',
|
||||
requiredScope: 'ops:tasks',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['remind_at', 'message'],
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
remind_at: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
description:
|
||||
'ISO-8601 datetime string in UTC or with offset at which to fire the reminder. ' +
|
||||
'Must be at least 1 minute in the future.',
|
||||
},
|
||||
message: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
maxLength: 500,
|
||||
description: 'The reminder message text to deliver.',
|
||||
},
|
||||
recipient: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Slack user ID (Uxxx), channel ID (Cxxx), or the literal "me" to target the ' +
|
||||
'calling user. Defaults to "me" when omitted.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
handler: async (
|
||||
input: CreateReminderInput,
|
||||
ctx: AuthContext
|
||||
): Promise<CreateReminderOutput> => {
|
||||
// Enforce the required scope server-side on every invocation.
|
||||
// requireScope throws ScopeError if the scope is missing.
|
||||
// TODO (DEFERRED auth layer — 0a gate): Real JWT/aud/client_id validation
|
||||
// is performed by the auth middleware layer, not here. requireScope only
|
||||
// checks the already-populated ctx.scopes array. Full issuer + aud
|
||||
// validation must be wired at the server/transport layer before deploying.
|
||||
requireScope(ctx, 'ops:tasks');
|
||||
|
||||
// Validate remind_at is parseable and in the future.
|
||||
const fireAt = new Date(input.remind_at);
|
||||
if (isNaN(fireAt.getTime())) {
|
||||
throw new Error(
|
||||
`create_reminder: invalid remind_at value "${input.remind_at}". ` +
|
||||
'Provide a valid ISO-8601 datetime string.'
|
||||
);
|
||||
}
|
||||
if (fireAt.getTime() <= Date.now() + 60_000) {
|
||||
throw new Error(
|
||||
'create_reminder: remind_at must be at least 1 minute in the future.'
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve the recipient: default to calling user sub.
|
||||
const recipient =
|
||||
!input.recipient || input.recipient === 'me' ? ctx.sub : input.recipient;
|
||||
|
||||
// Derive a safe, unique schedule name from the user sub + timestamp.
|
||||
// EventBridge schedule names: [a-zA-Z0-9_-], max 64 chars.
|
||||
const safeSubFragment = ctx.sub.replace(/[^a-zA-Z0-9]/g, '-').slice(0, 24);
|
||||
const ts = fireAt.getTime().toString();
|
||||
const scheduleName = `sh-reminder-${safeSubFragment}-${ts}`.slice(0, 64);
|
||||
|
||||
// Create the one-shot EventBridge schedule.
|
||||
await deps.client.createSchedule({
|
||||
scheduleName,
|
||||
scheduleAt: fireAt.toISOString(),
|
||||
// TARGET_ARN and SCHEDULER_ROLE_ARN come from CDK env vars at deploy time;
|
||||
// the client resolves them from its own config (not from the JWT).
|
||||
targetArn: process.env['REMINDER_TARGET_ARN'] ?? '',
|
||||
roleArn: process.env['SCHEDULER_ROLE_ARN'] ?? '',
|
||||
payload: {
|
||||
sub: ctx.sub,
|
||||
recipient,
|
||||
message: input.message,
|
||||
remind_at: fireAt.toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
// NOTE: This is an ops-tier tool. The message is user-supplied plain text;
|
||||
// no financial/PII fields are in scope here. redact() is not called because
|
||||
// this tool's output contains no bank/routing/card/SSN data. If that changes,
|
||||
// wrap sensitive string fields with redact() from @sh-mcp/shared.
|
||||
|
||||
return {
|
||||
reminder_id: scheduleName,
|
||||
remind_at: fireAt.toISOString(),
|
||||
confirmation:
|
||||
`Reminder scheduled for ${fireAt.toUTCString()} — ` +
|
||||
`"${input.message.slice(0, 80)}${input.message.length > 80 ? '…' : ''}"` +
|
||||
(recipient !== ctx.sub ? ` (recipient: ${recipient})` : ''),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
return [createReminder];
|
||||
}
|
||||
345
packages/reminders/test/reminders.test.ts
Normal file
345
packages/reminders/test/reminders.test.ts
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { SchedulerClient, CreateScheduleInput, CreateScheduleOutput } from '../src/client.js';
|
||||
import { buildReminderTools } from '../src/tools.js';
|
||||
import type { AuthContext } from '@sh-mcp/shared';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Returns a Date that is `offsetMs` milliseconds in the future. */
|
||||
function futureDate(offsetMs = 5 * 60 * 1000): Date {
|
||||
return new Date(Date.now() + offsetMs);
|
||||
}
|
||||
|
||||
/** AuthContext with ops:tasks scope — the happy-path identity. */
|
||||
function makeCtx(overrides: Partial<AuthContext> = {}): AuthContext {
|
||||
return {
|
||||
sub: 'lauren@seahavenind.com',
|
||||
scopes: ['ops:read', 'ops:tasks'],
|
||||
aud: 'sh-mcp-ops',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a mock SchedulerClient whose createSchedule can be controlled per-test. */
|
||||
function makeMockClient(
|
||||
impl?: (input: CreateScheduleInput) => Promise<CreateScheduleOutput>
|
||||
): SchedulerClient {
|
||||
return {
|
||||
createSchedule: vi.fn(
|
||||
impl ??
|
||||
(async (input: CreateScheduleInput): Promise<CreateScheduleOutput> => ({
|
||||
scheduleArn: `arn:aws:scheduler:us-east-1:328440206208:schedule/default/${input.scheduleName}`,
|
||||
}))
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Suite
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('create_reminder', () => {
|
||||
let mockClient: SchedulerClient;
|
||||
let createReminder: ReturnType<typeof buildReminderTools>[0];
|
||||
|
||||
beforeEach(() => {
|
||||
mockClient = makeMockClient();
|
||||
[createReminder] = buildReminderTools({ client: mockClient });
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Tool definition contract
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe('tool definition', () => {
|
||||
it('has the correct name', () => {
|
||||
expect(createReminder.name).toBe('create_reminder');
|
||||
});
|
||||
|
||||
it('is in the ops tier', () => {
|
||||
expect(createReminder.tier).toBe('ops');
|
||||
});
|
||||
|
||||
it('requires the ops:tasks scope', () => {
|
||||
expect(createReminder.requiredScope).toBe('ops:tasks');
|
||||
});
|
||||
|
||||
it('has a non-empty description', () => {
|
||||
expect(createReminder.description.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it('input schema declares remind_at and message as required', () => {
|
||||
const schema = createReminder.inputSchema as {
|
||||
required: string[];
|
||||
properties: Record<string, unknown>;
|
||||
};
|
||||
expect(schema.required).toContain('remind_at');
|
||||
expect(schema.required).toContain('message');
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Happy path
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe('happy path', () => {
|
||||
it('creates a schedule and returns a confirmation', async () => {
|
||||
const remindAt = futureDate(10 * 60 * 1000).toISOString();
|
||||
const ctx = makeCtx();
|
||||
|
||||
const result = await createReminder.handler(
|
||||
{ remind_at: remindAt, message: 'Pick up the dry cleaning' },
|
||||
ctx
|
||||
);
|
||||
|
||||
expect(result.remind_at).toBe(new Date(remindAt).toISOString());
|
||||
expect(result.reminder_id).toMatch(/^sh-reminder-/);
|
||||
expect(result.confirmation).toContain('Pick up the dry cleaning');
|
||||
});
|
||||
|
||||
it('passes the message and sub in the schedule payload', async () => {
|
||||
const remindAt = futureDate(10 * 60 * 1000).toISOString();
|
||||
const ctx = makeCtx();
|
||||
|
||||
await createReminder.handler(
|
||||
{ remind_at: remindAt, message: 'Follow up with vendor' },
|
||||
ctx
|
||||
);
|
||||
|
||||
const calls = (mockClient.createSchedule as ReturnType<typeof vi.fn>).mock.calls;
|
||||
expect(calls).toHaveLength(1);
|
||||
const [callInput] = calls[0] as [CreateScheduleInput];
|
||||
expect(callInput.payload['sub']).toBe('lauren@seahavenind.com');
|
||||
expect(callInput.payload['message']).toBe('Follow up with vendor');
|
||||
});
|
||||
|
||||
it('defaults recipient to the calling user sub when not provided', async () => {
|
||||
const remindAt = futureDate(10 * 60 * 1000).toISOString();
|
||||
const ctx = makeCtx();
|
||||
|
||||
await createReminder.handler({ remind_at: remindAt, message: 'Check email' }, ctx);
|
||||
|
||||
const [callInput] = (
|
||||
mockClient.createSchedule as ReturnType<typeof vi.fn>
|
||||
).mock.calls[0] as [CreateScheduleInput];
|
||||
expect(callInput.payload['recipient']).toBe('lauren@seahavenind.com');
|
||||
});
|
||||
|
||||
it('resolves recipient to sub when "me" is passed explicitly', async () => {
|
||||
const remindAt = futureDate(10 * 60 * 1000).toISOString();
|
||||
const ctx = makeCtx();
|
||||
|
||||
await createReminder.handler(
|
||||
{ remind_at: remindAt, message: 'Standup in 5', recipient: 'me' },
|
||||
ctx
|
||||
);
|
||||
|
||||
const [callInput] = (
|
||||
mockClient.createSchedule as ReturnType<typeof vi.fn>
|
||||
).mock.calls[0] as [CreateScheduleInput];
|
||||
expect(callInput.payload['recipient']).toBe('lauren@seahavenind.com');
|
||||
});
|
||||
|
||||
it('forwards a custom channel recipient unchanged', async () => {
|
||||
const remindAt = futureDate(10 * 60 * 1000).toISOString();
|
||||
const ctx = makeCtx();
|
||||
|
||||
await createReminder.handler(
|
||||
{ remind_at: remindAt, message: 'Team standup', recipient: 'C01234567' },
|
||||
ctx
|
||||
);
|
||||
|
||||
const [callInput] = (
|
||||
mockClient.createSchedule as ReturnType<typeof vi.fn>
|
||||
).mock.calls[0] as [CreateScheduleInput];
|
||||
expect(callInput.payload['recipient']).toBe('C01234567');
|
||||
});
|
||||
|
||||
it('scheduleAt is the ISO string of remind_at', async () => {
|
||||
const remindAt = futureDate(30 * 60 * 1000).toISOString();
|
||||
const ctx = makeCtx();
|
||||
|
||||
await createReminder.handler({ remind_at: remindAt, message: 'Check metrics' }, ctx);
|
||||
|
||||
const [callInput] = (
|
||||
mockClient.createSchedule as ReturnType<typeof vi.fn>
|
||||
).mock.calls[0] as [CreateScheduleInput];
|
||||
expect(callInput.scheduleAt).toBe(new Date(remindAt).toISOString());
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Scope enforcement
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe('scope enforcement', () => {
|
||||
it('throws when ops:tasks scope is missing', async () => {
|
||||
const ctx = makeCtx({ scopes: ['ops:read'] });
|
||||
const remindAt = futureDate(10 * 60 * 1000).toISOString();
|
||||
|
||||
await expect(
|
||||
createReminder.handler({ remind_at: remindAt, message: 'Unauthorized' }, ctx)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws when scopes array is empty', async () => {
|
||||
const ctx = makeCtx({ scopes: [] });
|
||||
const remindAt = futureDate(10 * 60 * 1000).toISOString();
|
||||
|
||||
await expect(
|
||||
createReminder.handler({ remind_at: remindAt, message: 'No scopes' }, ctx)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('succeeds when only ops:tasks is present (minimal grant)', async () => {
|
||||
const ctx = makeCtx({ scopes: ['ops:tasks'] });
|
||||
const remindAt = futureDate(10 * 60 * 1000).toISOString();
|
||||
|
||||
await expect(
|
||||
createReminder.handler({ remind_at: remindAt, message: 'Minimal scope' }, ctx)
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Input validation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe('input validation', () => {
|
||||
it('rejects an invalid remind_at string', async () => {
|
||||
const ctx = makeCtx();
|
||||
|
||||
await expect(
|
||||
createReminder.handler({ remind_at: 'not-a-date', message: 'Bad date' }, ctx)
|
||||
).rejects.toThrow(/invalid remind_at/);
|
||||
});
|
||||
|
||||
it('rejects a remind_at that is in the past', async () => {
|
||||
const ctx = makeCtx();
|
||||
const past = new Date(Date.now() - 60_000).toISOString();
|
||||
|
||||
await expect(
|
||||
createReminder.handler({ remind_at: past, message: 'Past date' }, ctx)
|
||||
).rejects.toThrow(/at least 1 minute in the future/);
|
||||
});
|
||||
|
||||
it('rejects a remind_at less than 1 minute in the future', async () => {
|
||||
const ctx = makeCtx();
|
||||
const tooSoon = new Date(Date.now() + 30_000).toISOString();
|
||||
|
||||
await expect(
|
||||
createReminder.handler({ remind_at: tooSoon, message: 'Too soon' }, ctx)
|
||||
).rejects.toThrow(/at least 1 minute in the future/);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Empty / minimal result
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles a single-character message without truncating confirmation', async () => {
|
||||
const ctx = makeCtx();
|
||||
const remindAt = futureDate(10 * 60 * 1000).toISOString();
|
||||
|
||||
const result = await createReminder.handler(
|
||||
{ remind_at: remindAt, message: 'A' },
|
||||
ctx
|
||||
);
|
||||
|
||||
expect(result.confirmation).toContain('"A"');
|
||||
});
|
||||
|
||||
it('truncates long messages in the confirmation with ellipsis', async () => {
|
||||
const ctx = makeCtx();
|
||||
const remindAt = futureDate(10 * 60 * 1000).toISOString();
|
||||
const longMessage = 'x'.repeat(200);
|
||||
|
||||
const result = await createReminder.handler(
|
||||
{ remind_at: remindAt, message: longMessage },
|
||||
ctx
|
||||
);
|
||||
|
||||
expect(result.confirmation).toContain('…');
|
||||
});
|
||||
|
||||
it('schedule name is within EventBridge name length limit (64 chars)', async () => {
|
||||
const ctx = makeCtx({ sub: 'a-very-long-user-sub-that-exceeds-typical-length@seahavenind.com' });
|
||||
const remindAt = futureDate(10 * 60 * 1000).toISOString();
|
||||
|
||||
const result = await createReminder.handler(
|
||||
{ remind_at: remindAt, message: 'Name length check' },
|
||||
ctx
|
||||
);
|
||||
|
||||
expect(result.reminder_id.length).toBeLessThanOrEqual(64);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Error path
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe('error handling', () => {
|
||||
it('surfaces a generic scheduler error to the caller', async () => {
|
||||
const failingClient = makeMockClient(async () => {
|
||||
throw new Error('Scheduler internal error');
|
||||
});
|
||||
const [failTool] = buildReminderTools({ client: failingClient });
|
||||
const ctx = makeCtx();
|
||||
const remindAt = futureDate(10 * 60 * 1000).toISOString();
|
||||
|
||||
await expect(
|
||||
failTool.handler({ remind_at: remindAt, message: 'Will fail' }, ctx)
|
||||
).rejects.toThrow('Scheduler internal error');
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Throttle / retry simulation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe('throttle / retry', () => {
|
||||
it('surfaces a ThrottlingException from the scheduler', async () => {
|
||||
let callCount = 0;
|
||||
const throttlingClient = makeMockClient(async () => {
|
||||
callCount += 1;
|
||||
const err = Object.assign(new Error('Rate exceeded'), {
|
||||
name: 'ThrottlingException',
|
||||
$fault: 'client',
|
||||
});
|
||||
throw err;
|
||||
});
|
||||
const [throttleTool] = buildReminderTools({ client: throttlingClient });
|
||||
const ctx = makeCtx();
|
||||
const remindAt = futureDate(10 * 60 * 1000).toISOString();
|
||||
|
||||
await expect(
|
||||
throttleTool.handler({ remind_at: remindAt, message: 'Throttled' }, ctx)
|
||||
).rejects.toThrow('Rate exceeded');
|
||||
|
||||
// The tool itself does not retry — retries are the transport/server layer's
|
||||
// responsibility. Assert the client was called exactly once.
|
||||
expect(callCount).toBe(1);
|
||||
});
|
||||
|
||||
it('surfaces a ConflictException when a schedule name already exists', async () => {
|
||||
const conflictClient = makeMockClient(async () => {
|
||||
const err = Object.assign(
|
||||
new Error('Schedule already exists with this name'),
|
||||
{ name: 'ConflictException' }
|
||||
);
|
||||
throw err;
|
||||
});
|
||||
const [conflictTool] = buildReminderTools({ client: conflictClient });
|
||||
const ctx = makeCtx();
|
||||
const remindAt = futureDate(10 * 60 * 1000).toISOString();
|
||||
|
||||
await expect(
|
||||
conflictTool.handler({ remind_at: remindAt, message: 'Duplicate' }, ctx)
|
||||
).rejects.toThrow('Schedule already exists');
|
||||
});
|
||||
});
|
||||
});
|
||||
9
packages/reminders/tsconfig.json
Normal file
9
packages/reminders/tsconfig.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"declarationDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
32
packages/shared/package.json
Normal file
32
packages/shared/package.json
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"name": "@sh-mcp/shared",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Transport-agnostic core — types, registry, auth interface, redaction, OpenAPI generation",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --project tsconfig.json",
|
||||
"typecheck": "tsc --noEmit --project tsconfig.json",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/coverage-v8": "^2.0.0",
|
||||
"typescript": "^5.5.0",
|
||||
"vitest": "^2.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"jose": "^6.2.3"
|
||||
}
|
||||
}
|
||||
113
packages/shared/src/auth.ts
Normal file
113
packages/shared/src/auth.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
/**
|
||||
* Authorization interface and scope-enforcement guard.
|
||||
*
|
||||
* This file defines the scope-enforcement contract every tool handler relies on:
|
||||
* 1. `ScopeError` — a typed error thrown when a required scope is absent.
|
||||
* 2. `requireScope()` — enforces scope presence on an already-decoded
|
||||
* AuthContext. Call this at the top of every tool handler.
|
||||
* 3. `AuthProvider` interface — the contract the auth layer implements. Each
|
||||
* server wires an AuthProvider into its request pipeline so that by the
|
||||
* time `handler(input, ctx)` is called the context is already validated.
|
||||
*
|
||||
* The concrete implementation of `AuthProvider` now lives in `cognito-auth.ts`
|
||||
* (`CognitoAuthProvider`) — built and verified after the 0a spike proved the
|
||||
* live Cognito access-token shape (verified `sub`/`scope`/`client_id`, no native
|
||||
* `aud`). It verifies the JWT signature against the pool JWKS, validates `iss`,
|
||||
* enforces the `client_id` allow-list AS the audience boundary (the token has no
|
||||
* `aud`), applies the finance TTL ceiling and deny-list, and extracts scopes.
|
||||
* This file stays transport- and provider-agnostic; see docs/design.md §2.
|
||||
*/
|
||||
|
||||
import type { AuthContext, Scope } from './types.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ScopeError
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Thrown by `requireScope()` when the caller's AuthContext does not include
|
||||
* the required scope.
|
||||
*
|
||||
* Transport adapters (OpenAPI handler, MCP dispatcher) should catch this and
|
||||
* return an appropriate 403 / permission-denied response.
|
||||
*/
|
||||
export class ScopeError extends Error {
|
||||
/** The scope that was required but absent. */
|
||||
readonly requiredScope: Scope;
|
||||
/** The `sub` from the AuthContext that triggered the error. */
|
||||
readonly sub: string;
|
||||
|
||||
constructor(sub: string, requiredScope: Scope) {
|
||||
super(
|
||||
`User "${sub}" does not have the required scope "${requiredScope}".`,
|
||||
);
|
||||
this.name = 'ScopeError';
|
||||
this.requiredScope = requiredScope;
|
||||
this.sub = sub;
|
||||
// Maintain proper prototype chain for `instanceof` checks.
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// requireScope
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Assert that `ctx` contains `scope`. Throws `ScopeError` if it does not.
|
||||
*
|
||||
* Call at the top of every tool handler before touching any input or reaching
|
||||
* any downstream service:
|
||||
*
|
||||
* ```ts
|
||||
* handler: async (input, ctx) => {
|
||||
* requireScope(ctx, 'finance:read');
|
||||
* // ... safe to proceed
|
||||
* },
|
||||
* ```
|
||||
*
|
||||
* NOTE: Server-side enforcement is authoritative. Tool-hiding in the agent UI
|
||||
* is a convenience only (design.md §2.5). This guard enforces independently.
|
||||
*
|
||||
* NOTE: JWT signature/issuer/aud/client_id validation is NOT done here — see
|
||||
* the TODO above. By the time `handler` is called, the `AuthProvider` has
|
||||
* already validated the token and populated `ctx`.
|
||||
*/
|
||||
export function requireScope(ctx: AuthContext, scope: Scope): void {
|
||||
if (!ctx.scopes.includes(scope)) {
|
||||
throw new ScopeError(ctx.sub, scope);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AuthProvider interface (deferred 0a-gated auth layer contract)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Contract that each server's concrete auth layer must implement.
|
||||
*
|
||||
* The server's request pipeline calls `authenticate(req)` once per inbound
|
||||
* request and passes the resolved `AuthContext` into every tool handler.
|
||||
*
|
||||
* `req` is typed as `unknown` so this interface stays transport-agnostic
|
||||
* (works for an Express `Request`, a raw `IncomingMessage`, a Lambda event,
|
||||
* or a test-double). The concrete implementation casts to the appropriate type.
|
||||
*
|
||||
* TODO(auth-layer-0a): Implement this interface in the deferred auth layer.
|
||||
* A concrete implementation lives in `servers/sh-mcp-ops/src/auth.ts` and
|
||||
* `servers/sh-mcp-finance/src/auth.ts` once that layer is built.
|
||||
*/
|
||||
export interface AuthProvider {
|
||||
/**
|
||||
* Extract and validate the inbound token from `req`, returning a fully
|
||||
* populated AuthContext on success.
|
||||
*
|
||||
* Throws (or rejects) on any validation failure:
|
||||
* - Missing / malformed Authorization header
|
||||
* - Invalid JWT signature
|
||||
* - Wrong issuer, audience, or client_id
|
||||
* - Expired token
|
||||
* - User on the deny-list
|
||||
*/
|
||||
authenticate(req: unknown): Promise<AuthContext>;
|
||||
}
|
||||
263
packages/shared/src/cognito-auth.test.ts
Normal file
263
packages/shared/src/cognito-auth.test.ts
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
/**
|
||||
* Security-weighted tests for the Cognito auth layer.
|
||||
*
|
||||
* The token claims here mirror the shape the 0a spike actually observed from
|
||||
* live Cognito (access token: prefixed `scope` string, `client_id`, `token_use`,
|
||||
* no native `aud`). The audience-boundary, scope-isolation, TTL-ceiling and
|
||||
* revocation cases are the trust-tier guarantees from design.md §2 — they are
|
||||
* the reason this file carries the heaviest coverage in the platform.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { generateKeyPair, SignJWT, type JWTVerifyGetKey } from 'jose';
|
||||
|
||||
import {
|
||||
CognitoAuthProvider,
|
||||
AuthError,
|
||||
extractScopes,
|
||||
extractBearerToken,
|
||||
cognitoIssuer,
|
||||
type CognitoAuthConfig,
|
||||
} from './cognito-auth.js';
|
||||
import { requireScope } from './auth.js';
|
||||
|
||||
type KeyPair = Awaited<ReturnType<typeof generateKeyPair>>;
|
||||
|
||||
const ISSUER = cognitoIssuer('us-east-1', 'us-east-1_TESTPOOL');
|
||||
const OPS_CLIENT = 'ops-app-client-id';
|
||||
const FIN_CLIENT = 'finance-app-client-id';
|
||||
|
||||
// Signing keys for the suite, plus a second pair to forge bad signatures.
|
||||
const signing: KeyPair = await generateKeyPair('RS256');
|
||||
const attacker: KeyPair = await generateKeyPair('RS256');
|
||||
|
||||
/** A JWKS resolver that returns our test public key (stands in for the pool's JWKS). */
|
||||
const jwks: JWTVerifyGetKey = async () => signing.publicKey;
|
||||
|
||||
function baseConfig(overrides: Partial<CognitoAuthConfig> = {}): CognitoAuthConfig {
|
||||
return {
|
||||
issuer: ISSUER,
|
||||
audience: 'sh-mcp-ops',
|
||||
allowedClientIds: [OPS_CLIENT],
|
||||
scopePrefix: 'sh-mcp-ops',
|
||||
jwks,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function financeConfig(overrides: Partial<CognitoAuthConfig> = {}): CognitoAuthConfig {
|
||||
return baseConfig({
|
||||
audience: 'sh-mcp-finance',
|
||||
allowedClientIds: [FIN_CLIENT],
|
||||
scopePrefix: 'sh-mcp-finance',
|
||||
maxTtlSeconds: 900,
|
||||
ttlGuardedScopes: ['finance:read', 'finance:admin'],
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
interface MintOpts {
|
||||
issuer?: string;
|
||||
clientId?: string;
|
||||
scope?: string;
|
||||
tokenUse?: string;
|
||||
sub?: string;
|
||||
iat?: number;
|
||||
ttlSeconds?: number;
|
||||
signer?: KeyPair['privateKey'];
|
||||
}
|
||||
|
||||
async function mint(opts: MintOpts = {}): Promise<string> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const iat = opts.iat ?? now;
|
||||
const ttl = opts.ttlSeconds ?? 3600;
|
||||
return new SignJWT({
|
||||
token_use: opts.tokenUse ?? 'access',
|
||||
client_id: opts.clientId ?? OPS_CLIENT,
|
||||
scope: opts.scope ?? 'sh-mcp-ops/ops:read sh-mcp-ops/ops:tasks openid email',
|
||||
})
|
||||
.setProtectedHeader({ alg: 'RS256' })
|
||||
.setSubject(opts.sub ?? 'user-sub-123')
|
||||
.setIssuer(opts.issuer ?? ISSUER)
|
||||
.setIssuedAt(iat)
|
||||
.setExpirationTime(iat + ttl)
|
||||
.sign(opts.signer ?? signing.privateKey);
|
||||
}
|
||||
|
||||
describe('CognitoAuthProvider.authenticate', () => {
|
||||
it('accepts a valid access token and extracts sub + this tier’s scopes', async () => {
|
||||
const provider = new CognitoAuthProvider(baseConfig());
|
||||
const ctx = await provider.authenticate(`Bearer ${await mint()}`);
|
||||
expect(ctx.sub).toBe('user-sub-123');
|
||||
expect(ctx.aud).toBe('sh-mcp-ops');
|
||||
expect(ctx.scopes).toEqual(['ops:read', 'ops:tasks']);
|
||||
});
|
||||
|
||||
it('drops cross-tier and standard (openid/email) scopes', async () => {
|
||||
const provider = new CognitoAuthProvider(baseConfig());
|
||||
const token = await mint({
|
||||
scope: 'sh-mcp-ops/ops:read sh-mcp-finance/finance:read openid email',
|
||||
});
|
||||
const ctx = await provider.authenticate(`Bearer ${token}`);
|
||||
expect(ctx.scopes).toEqual(['ops:read']);
|
||||
});
|
||||
|
||||
it('rejects a token from the wrong issuer', async () => {
|
||||
const provider = new CognitoAuthProvider(baseConfig());
|
||||
const token = await mint({ issuer: 'https://evil.example.com/pool' });
|
||||
await expect(provider.authenticate(`Bearer ${token}`)).rejects.toMatchObject({
|
||||
code: 'invalid_token',
|
||||
});
|
||||
});
|
||||
|
||||
it('AUDIENCE BOUNDARY: rejects an ops token presented to the finance server', async () => {
|
||||
const finance = new CognitoAuthProvider(financeConfig());
|
||||
const opsToken = await mint({ clientId: OPS_CLIENT, scope: 'sh-mcp-ops/ops:read' });
|
||||
await expect(finance.authenticate(`Bearer ${opsToken}`)).rejects.toMatchObject({
|
||||
code: 'client_not_allowed',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an id token (token_use !== "access")', async () => {
|
||||
const provider = new CognitoAuthProvider(baseConfig());
|
||||
const token = await mint({ tokenUse: 'id' });
|
||||
await expect(provider.authenticate(`Bearer ${token}`)).rejects.toMatchObject({
|
||||
code: 'invalid_token',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an expired token', async () => {
|
||||
const provider = new CognitoAuthProvider(baseConfig());
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const token = await mint({ iat: now - 7200, ttlSeconds: 3600 }); // expired ~1h ago
|
||||
await expect(provider.authenticate(`Bearer ${token}`)).rejects.toMatchObject({
|
||||
code: 'invalid_token',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a token signed by an unknown key (forged signature)', async () => {
|
||||
const provider = new CognitoAuthProvider(baseConfig());
|
||||
const token = await mint({ signer: attacker.privateKey });
|
||||
await expect(provider.authenticate(`Bearer ${token}`)).rejects.toMatchObject({
|
||||
code: 'invalid_token',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a missing Authorization header', async () => {
|
||||
const provider = new CognitoAuthProvider(baseConfig());
|
||||
await expect(provider.authenticate({ headers: {} })).rejects.toMatchObject({
|
||||
code: 'missing_token',
|
||||
});
|
||||
});
|
||||
|
||||
it('FINANCE TTL: rejects a finance-scoped token whose lifetime exceeds the ceiling', async () => {
|
||||
const finance = new CognitoAuthProvider(financeConfig());
|
||||
const longToken = await mint({
|
||||
clientId: FIN_CLIENT,
|
||||
scope: 'sh-mcp-finance/finance:read',
|
||||
ttlSeconds: 3600,
|
||||
});
|
||||
await expect(finance.authenticate(`Bearer ${longToken}`)).rejects.toMatchObject({
|
||||
code: 'ttl_exceeded',
|
||||
});
|
||||
});
|
||||
|
||||
it('FINANCE TTL: accepts a finance-scoped token within the ceiling', async () => {
|
||||
const finance = new CognitoAuthProvider(financeConfig());
|
||||
const shortToken = await mint({
|
||||
clientId: FIN_CLIENT,
|
||||
scope: 'sh-mcp-finance/finance:read',
|
||||
ttlSeconds: 600,
|
||||
});
|
||||
const ctx = await finance.authenticate(`Bearer ${shortToken}`);
|
||||
expect(ctx.scopes).toEqual(['finance:read']);
|
||||
});
|
||||
|
||||
it('does not apply the TTL ceiling to non-guarded scopes', async () => {
|
||||
const finance = new CognitoAuthProvider(financeConfig());
|
||||
// ops:read carried under the finance prefix is known but not TTL-guarded.
|
||||
const longToken = await mint({
|
||||
clientId: FIN_CLIENT,
|
||||
scope: 'sh-mcp-finance/ops:read',
|
||||
ttlSeconds: 3600,
|
||||
});
|
||||
const ctx = await finance.authenticate(`Bearer ${longToken}`);
|
||||
expect(ctx.scopes).toEqual(['ops:read']);
|
||||
});
|
||||
|
||||
it('rejects a revoked (deny-listed) user', async () => {
|
||||
const provider = new CognitoAuthProvider(
|
||||
baseConfig({ denyList: { isDenied: async (sub) => sub === 'revoked-user' } }),
|
||||
);
|
||||
const token = await mint({ sub: 'revoked-user' });
|
||||
await expect(provider.authenticate(`Bearer ${token}`)).rejects.toMatchObject({
|
||||
code: 'revoked',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a context that satisfies requireScope for granted scopes only', async () => {
|
||||
const provider = new CognitoAuthProvider(baseConfig());
|
||||
const ctx = await provider.authenticate(`Bearer ${await mint()}`);
|
||||
expect(() => requireScope(ctx, 'ops:read')).not.toThrow();
|
||||
expect(() => requireScope(ctx, 'finance:read')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractScopes', () => {
|
||||
it('strips the tier prefix and keeps known scopes in order', () => {
|
||||
expect(extractScopes('sh-mcp-ops/ops:read sh-mcp-ops/ops:tasks', 'sh-mcp-ops')).toEqual([
|
||||
'ops:read',
|
||||
'ops:tasks',
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops other tiers and standard scopes', () => {
|
||||
expect(
|
||||
extractScopes('sh-mcp-ops/ops:read sh-mcp-finance/finance:read openid email', 'sh-mcp-ops'),
|
||||
).toEqual(['ops:read']);
|
||||
});
|
||||
|
||||
it('drops prefixed-but-unknown scopes', () => {
|
||||
expect(extractScopes('sh-mcp-ops/bogus:scope', 'sh-mcp-ops')).toEqual([]);
|
||||
});
|
||||
|
||||
it('de-duplicates', () => {
|
||||
expect(extractScopes('sh-mcp-ops/ops:read sh-mcp-ops/ops:read', 'sh-mcp-ops')).toEqual([
|
||||
'ops:read',
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles empty / non-string input', () => {
|
||||
expect(extractScopes('', 'sh-mcp-ops')).toEqual([]);
|
||||
expect(extractScopes(undefined, 'sh-mcp-ops')).toEqual([]);
|
||||
expect(extractScopes(null, 'sh-mcp-ops')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractBearerToken', () => {
|
||||
it('reads a raw Authorization header string', () => {
|
||||
expect(extractBearerToken('Bearer abc.def.ghi')).toBe('abc.def.ghi');
|
||||
});
|
||||
|
||||
it('is case-insensitive on the scheme', () => {
|
||||
expect(extractBearerToken('bearer abc')).toBe('abc');
|
||||
});
|
||||
|
||||
it('reads a plain headers object (either header casing)', () => {
|
||||
expect(extractBearerToken({ headers: { authorization: 'Bearer xyz' } })).toBe('xyz');
|
||||
expect(extractBearerToken({ headers: { Authorization: 'Bearer XYZ' } })).toBe('XYZ');
|
||||
});
|
||||
|
||||
it('reads a Fetch Headers-like object', () => {
|
||||
const headers = new Headers({ authorization: 'Bearer fetchtoken' });
|
||||
expect(extractBearerToken({ headers })).toBe('fetchtoken');
|
||||
});
|
||||
|
||||
it('throws AuthError on a missing header', () => {
|
||||
expect(() => extractBearerToken({ headers: {} })).toThrow(AuthError);
|
||||
});
|
||||
|
||||
it('throws AuthError on a non-Bearer header', () => {
|
||||
expect(() => extractBearerToken('Basic abc')).toThrow(AuthError);
|
||||
});
|
||||
});
|
||||
302
packages/shared/src/cognito-auth.ts
Normal file
302
packages/shared/src/cognito-auth.ts
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
/**
|
||||
* Concrete `AuthProvider` for Amazon Cognito access tokens.
|
||||
*
|
||||
* This is the real implementation the 0a spike unblocked. The spike proved the
|
||||
* exact token shape we receive (see SPIKE findings / design.md §2): a Cognito
|
||||
* **access** token carries `sub`, `client_id`, `scope` (a space-separated,
|
||||
* resource-server-prefixed string), `iss`, `token_use`, `iat`, `exp` — and
|
||||
* crucially **no native `aud` claim and no `email`**. Two consequences drive
|
||||
* this implementation:
|
||||
*
|
||||
* 1. Audience binding cannot use the JWT `aud` claim. Instead the
|
||||
* **`client_id` allow-list IS the audience boundary** — each trust tier
|
||||
* gets its own Cognito app client, and a server only accepts tokens minted
|
||||
* by app clients on its `allowedClientIds`. An ops token presented to the
|
||||
* finance server is rejected because the ops app-client id is not on
|
||||
* finance's allow-list (design.md §2.5).
|
||||
* 2. Scopes arrive prefixed with the resource server ("sh-mcp-ops/ops:read").
|
||||
* We accept only scopes carrying this server's `scopePrefix`, strip the
|
||||
* prefix to the internal `Scope` ("ops:read"), and drop everything else —
|
||||
* so a cross-tier scope can never leak into an AuthContext.
|
||||
*
|
||||
* The class verifies the JWT signature against the pool's JWKS, validates the
|
||||
* issuer, enforces the client_id allow-list, applies the optional finance TTL
|
||||
* ceiling and deny-list, and returns a populated `AuthContext`. Scope-per-tool
|
||||
* enforcement still happens in each handler via `requireScope()` (see auth.ts).
|
||||
*
|
||||
* Config (client_id allow-list, audience, scope prefix, TTL rule) is INJECTED,
|
||||
* never hardcoded — it comes from each server's SSM/CDK env (design.md §2,
|
||||
* memory: "the client/audience matrix is config, not hardcoded into the core").
|
||||
*/
|
||||
|
||||
import { jwtVerify, createRemoteJWKSet, type JWTVerifyGetKey, type JWTPayload } from 'jose';
|
||||
|
||||
import type { AuthContext, Scope } from './types.js';
|
||||
import type { AuthProvider } from './auth.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AuthError
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Reason an inbound token was rejected. Distinct from `ScopeError`
|
||||
* (auth.ts): an `AuthError` is an authentication failure (→ HTTP 401),
|
||||
* whereas a `ScopeError` is an authorization failure on a valid identity
|
||||
* (→ HTTP 403).
|
||||
*/
|
||||
export type AuthErrorCode =
|
||||
| 'missing_token' // no / malformed Authorization header
|
||||
| 'invalid_token' // bad signature, wrong issuer, not an access token, no sub
|
||||
| 'client_not_allowed' // client_id absent from this server's allow-list (audience boundary)
|
||||
| 'ttl_exceeded' // token lifetime exceeds the ceiling for a guarded scope (finance)
|
||||
| 'revoked'; // user is on the deny-list
|
||||
|
||||
/** Thrown by `CognitoAuthProvider.authenticate()` on any authentication failure. */
|
||||
export class AuthError extends Error {
|
||||
readonly code: AuthErrorCode;
|
||||
|
||||
constructor(code: AuthErrorCode, message: string) {
|
||||
super(message);
|
||||
this.name = 'AuthError';
|
||||
this.code = code;
|
||||
// Maintain proper prototype chain for `instanceof` checks.
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Optional immediate-revocation check (design.md §2.3 — Cognito-backed deny-list). */
|
||||
export interface DenyListChecker {
|
||||
/** Resolve `true` if `sub` has been hard-revoked and must be rejected now. */
|
||||
isDenied(sub: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-server configuration for {@link CognitoAuthProvider}. Injected from each
|
||||
* server's environment — never hardcoded into the shared core.
|
||||
*/
|
||||
export interface CognitoAuthConfig {
|
||||
/**
|
||||
* Expected token issuer — the Cognito user pool URL,
|
||||
* e.g. `https://cognito-idp.us-east-1.amazonaws.com/us-east-1_GsDbGe0pa`.
|
||||
* Tokens with any other `iss` are rejected. Use {@link cognitoIssuer}.
|
||||
*/
|
||||
issuer: string;
|
||||
/**
|
||||
* This server's resource-server identifier, written into `AuthContext.aud`
|
||||
* after the client_id allow-list passes (e.g. `"sh-mcp-ops"`). Because the
|
||||
* token carries no native `aud`, this is the server's asserted audience, not
|
||||
* a value read from the token.
|
||||
*/
|
||||
audience: string;
|
||||
/**
|
||||
* Cognito app-client ids permitted to call THIS server. This list IS the
|
||||
* audience boundary (the token has no `aud`): a token minted for another
|
||||
* tier's app client is rejected. Each tier has its own app client.
|
||||
*/
|
||||
allowedClientIds: readonly string[];
|
||||
/**
|
||||
* Resource-server prefix for this tier, e.g. `"sh-mcp-ops"`. Cognito scopes
|
||||
* arrive as `"sh-mcp-ops/ops:read"`; only scopes with this prefix are
|
||||
* accepted, the prefix is stripped to the internal `Scope`, and all other
|
||||
* (cross-tier or standard `openid`/`email`) scopes are dropped.
|
||||
*/
|
||||
scopePrefix: string;
|
||||
/**
|
||||
* JWKS key resolver used to verify the token signature. In production build
|
||||
* it with {@link cognitoJwks}; tests inject a local key set.
|
||||
*/
|
||||
jwks: JWTVerifyGetKey;
|
||||
/**
|
||||
* If set, a token whose lifetime (`exp - iat`) exceeds this many seconds is
|
||||
* rejected when it carries any scope in {@link ttlGuardedScopes}. design.md
|
||||
* §2.5 requires `finance:*` tokens to be ≤ 15 min (900s).
|
||||
*/
|
||||
maxTtlSeconds?: number;
|
||||
/** Scopes that trigger the {@link maxTtlSeconds} ceiling (e.g. the finance scopes). */
|
||||
ttlGuardedScopes?: readonly Scope[];
|
||||
/** Optional deny-list for immediate hard revocation of a `sub`. */
|
||||
denyList?: DenyListChecker;
|
||||
/** Clock skew tolerance in seconds for `exp`/`nbf` (default 5). */
|
||||
clockToleranceSeconds?: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JWKS / issuer helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** The Cognito issuer URL for a pool — use for {@link CognitoAuthConfig.issuer}. */
|
||||
export function cognitoIssuer(region: string, userPoolId: string): string {
|
||||
return `https://cognito-idp.${region}.amazonaws.com/${userPoolId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a cached remote JWKS resolver for a Cognito user pool. The resolver
|
||||
* fetches and caches the pool's signing keys, refreshing on unknown `kid`.
|
||||
*/
|
||||
export function cognitoJwks(region: string, userPoolId: string): JWTVerifyGetKey {
|
||||
return createRemoteJWKSet(new URL(`${cognitoIssuer(region, userPoolId)}/.well-known/jwks.json`));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure helpers (exported for unit testing)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Every internal scope the platform recognizes (cross-checked from types.ts). */
|
||||
const KNOWN_SCOPES: readonly Scope[] = [
|
||||
'ops:read',
|
||||
'ops:tasks',
|
||||
'gmail:self',
|
||||
'calendar:self',
|
||||
'finance:read',
|
||||
'finance:admin',
|
||||
];
|
||||
|
||||
/**
|
||||
* Parse a Cognito `scope` string into validated internal `Scope`s for one tier.
|
||||
*
|
||||
* Keeps only entries prefixed `"<scopePrefix>/"`, strips the prefix, and admits
|
||||
* the result only if it is a {@link KNOWN_SCOPES} value. Standard scopes
|
||||
* (`openid`, `email`) and other tiers' scopes are dropped. Order-preserving and
|
||||
* de-duplicated.
|
||||
*/
|
||||
export function extractScopes(rawScope: unknown, scopePrefix: string): Scope[] {
|
||||
if (typeof rawScope !== 'string' || rawScope.length === 0) return [];
|
||||
const wanted = `${scopePrefix}/`;
|
||||
const out: Scope[] = [];
|
||||
for (const entry of rawScope.split(/\s+/)) {
|
||||
if (!entry.startsWith(wanted)) continue;
|
||||
const bare = entry.slice(wanted.length);
|
||||
if ((KNOWN_SCOPES as readonly string[]).includes(bare) && !out.includes(bare as Scope)) {
|
||||
out.push(bare as Scope);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the bearer token out of a transport-agnostic request. Accepts the raw
|
||||
* Authorization header string, a Fetch `Headers`-like object, or a plain
|
||||
* `{ headers: { authorization } }` shape (Node `IncomingMessage`, Lambda event).
|
||||
*
|
||||
* @throws {AuthError} `missing_token` if no usable Bearer token is present.
|
||||
*/
|
||||
export function extractBearerToken(req: unknown): string {
|
||||
const header = getAuthorizationHeader(req);
|
||||
if (!header) {
|
||||
throw new AuthError('missing_token', 'No Authorization header present.');
|
||||
}
|
||||
const match = /^Bearer\s+(.+)$/i.exec(header.trim());
|
||||
const token = match?.[1]?.trim();
|
||||
if (!token) {
|
||||
throw new AuthError('missing_token', 'Authorization header is not a Bearer token.');
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
function getAuthorizationHeader(req: unknown): string | undefined {
|
||||
if (typeof req === 'string') return req;
|
||||
if (req === null || typeof req !== 'object') return undefined;
|
||||
|
||||
const headers = (req as { headers?: unknown }).headers;
|
||||
if (!headers || typeof headers !== 'object') return undefined;
|
||||
|
||||
// Fetch `Headers`-like (has a .get method).
|
||||
const get = (headers as { get?: unknown }).get;
|
||||
if (typeof get === 'function') {
|
||||
const v = (headers as Headers).get('authorization');
|
||||
return v ?? undefined;
|
||||
}
|
||||
|
||||
// Plain object headers (case-insensitive lookup, array-valued allowed).
|
||||
const h = headers as Record<string, unknown>;
|
||||
const v = h['authorization'] ?? h['Authorization'];
|
||||
if (typeof v === 'string') return v;
|
||||
if (Array.isArray(v) && typeof v[0] === 'string') return v[0];
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CognitoAuthProvider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Verifies a Cognito access token and resolves it to an {@link AuthContext}.
|
||||
*
|
||||
* Each server constructs one with its own config and calls `authenticate(req)`
|
||||
* once per inbound request, before any tool handler runs.
|
||||
*/
|
||||
export class CognitoAuthProvider implements AuthProvider {
|
||||
constructor(private readonly config: CognitoAuthConfig) {}
|
||||
|
||||
async authenticate(req: unknown): Promise<AuthContext> {
|
||||
const token = extractBearerToken(req);
|
||||
|
||||
let payload: JWTPayload & {
|
||||
token_use?: unknown;
|
||||
client_id?: unknown;
|
||||
scope?: unknown;
|
||||
};
|
||||
try {
|
||||
const result = await jwtVerify(token, this.config.jwks, {
|
||||
issuer: this.config.issuer,
|
||||
clockTolerance: this.config.clockToleranceSeconds ?? 5,
|
||||
});
|
||||
payload = result.payload;
|
||||
} catch (err) {
|
||||
throw new AuthError('invalid_token', `Token verification failed: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
// Must be an access token — id tokens carry different claims and are not
|
||||
// the credential the agent callout presents.
|
||||
if (payload.token_use !== 'access') {
|
||||
throw new AuthError(
|
||||
'invalid_token',
|
||||
`Expected token_use "access", got "${String(payload.token_use)}".`,
|
||||
);
|
||||
}
|
||||
|
||||
// client_id allow-list == audience boundary (token has no native aud).
|
||||
const clientId = typeof payload.client_id === 'string' ? payload.client_id : undefined;
|
||||
if (!clientId || !this.config.allowedClientIds.includes(clientId)) {
|
||||
throw new AuthError(
|
||||
'client_not_allowed',
|
||||
`client_id "${clientId ?? '(none)'}" is not permitted for audience "${this.config.audience}".`,
|
||||
);
|
||||
}
|
||||
|
||||
const sub = typeof payload.sub === 'string' ? payload.sub : undefined;
|
||||
if (!sub) {
|
||||
throw new AuthError('invalid_token', 'Token has no "sub" claim.');
|
||||
}
|
||||
|
||||
const scopes = extractScopes(payload.scope, this.config.scopePrefix);
|
||||
|
||||
// Finance TTL ceiling (design.md §2.5): a token bearing a guarded scope must
|
||||
// be short-lived. exp/iat are validated numbers here (jwtVerify checked exp).
|
||||
if (this.config.maxTtlSeconds != null && this.config.ttlGuardedScopes?.length) {
|
||||
const carriesGuarded = scopes.some((s) => this.config.ttlGuardedScopes!.includes(s));
|
||||
if (carriesGuarded) {
|
||||
const iat = typeof payload.iat === 'number' ? payload.iat : undefined;
|
||||
const exp = typeof payload.exp === 'number' ? payload.exp : undefined;
|
||||
if (iat == null || exp == null || exp - iat > this.config.maxTtlSeconds) {
|
||||
throw new AuthError(
|
||||
'ttl_exceeded',
|
||||
`Token lifetime exceeds the ${this.config.maxTtlSeconds}s ceiling required for ` +
|
||||
`${this.config.ttlGuardedScopes!.join('/')} scopes.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Immediate hard revocation (checked last — most expensive, may hit DynamoDB).
|
||||
if (this.config.denyList && (await this.config.denyList.isDenied(sub))) {
|
||||
throw new AuthError('revoked', `User "${sub}" is on the deny-list (revoked).`);
|
||||
}
|
||||
|
||||
return { sub, scopes, aud: this.config.audience };
|
||||
}
|
||||
}
|
||||
41
packages/shared/src/index.ts
Normal file
41
packages/shared/src/index.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* @sh-mcp/shared — public API
|
||||
*
|
||||
* Every package that defines tools imports ONLY from this barrel.
|
||||
* Do NOT import from sub-paths like '@sh-mcp/shared/src/auth'.
|
||||
*/
|
||||
|
||||
// Types
|
||||
export type { Scope, AuthContext, ToolDef, JSONSchema } from './types.js';
|
||||
|
||||
// Registry
|
||||
export { defineTool, ToolRegistry } from './registry.js';
|
||||
|
||||
// Auth interface and scope guard
|
||||
export { requireScope, ScopeError } from './auth.js';
|
||||
export type { AuthProvider } from './auth.js';
|
||||
|
||||
// Concrete Cognito auth provider (the 0a-unblocked implementation)
|
||||
export {
|
||||
CognitoAuthProvider,
|
||||
AuthError,
|
||||
cognitoIssuer,
|
||||
cognitoJwks,
|
||||
extractScopes,
|
||||
extractBearerToken,
|
||||
} from './cognito-auth.js';
|
||||
export type { AuthErrorCode, CognitoAuthConfig, DenyListChecker } from './cognito-auth.js';
|
||||
|
||||
// Redaction
|
||||
export { redact, maskValue, REDACTED } from './redact.js';
|
||||
|
||||
// OpenAPI generation
|
||||
export { generateOpenAPIPaths } from './openapi.js';
|
||||
export type {
|
||||
OASPathsResult,
|
||||
OASPathItem,
|
||||
OASOperation,
|
||||
OASRequestBody,
|
||||
OASResponse,
|
||||
OASMediaType,
|
||||
} from './openapi.js';
|
||||
309
packages/shared/src/openapi.test.ts
Normal file
309
packages/shared/src/openapi.test.ts
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { ToolRegistry, defineTool } from './registry.js';
|
||||
import { generateOpenAPIPaths } from './openapi.js';
|
||||
import type { OASPathsResult } from './openapi.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const lookupWorkOrder = defineTool({
|
||||
name: 'lookup-work-order',
|
||||
description: 'Look up a work order by ID.',
|
||||
tier: 'ops',
|
||||
requiredScope: 'ops:read',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['workOrderId'],
|
||||
properties: {
|
||||
workOrderId: { type: 'string', description: 'The work order ID.' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
handler: async (_input, _ctx) => ({ id: 'WO-001', status: 'open' }),
|
||||
});
|
||||
|
||||
const searchVendors = defineTool({
|
||||
name: 'search-vendors',
|
||||
description: 'Search vendors in QuickBooks Online.',
|
||||
tier: 'finance',
|
||||
requiredScope: 'finance:read',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['query'],
|
||||
properties: {
|
||||
query: { type: 'string', description: 'Vendor name or keyword.' },
|
||||
limit: { type: 'integer', default: 10 },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
handler: async (_input, _ctx) => ({ vendors: [] }),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildRegistry(...tools: ReturnType<typeof defineTool>[]): ToolRegistry {
|
||||
const reg = new ToolRegistry();
|
||||
for (const t of tools) reg.register(t);
|
||||
return reg;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('generateOpenAPIPaths', () => {
|
||||
let result: OASPathsResult;
|
||||
|
||||
beforeEach(() => {
|
||||
const registry = buildRegistry(lookupWorkOrder, searchVendors);
|
||||
result = generateOpenAPIPaths(registry);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Basic structure
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('produces a paths object', () => {
|
||||
expect(result).toHaveProperty('paths');
|
||||
expect(typeof result.paths).toBe('object');
|
||||
});
|
||||
|
||||
it('produces one path per registered tool', () => {
|
||||
expect(Object.keys(result.paths)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('generates the correct path key for each tool', () => {
|
||||
expect(result.paths).toHaveProperty('/tools/lookup-work-order');
|
||||
expect(result.paths).toHaveProperty('/tools/search-vendors');
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// POST operation structure
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('wraps each tool in a POST operation', () => {
|
||||
const pathItem = result.paths['/tools/lookup-work-order']!;
|
||||
expect(pathItem).toHaveProperty('post');
|
||||
expect(pathItem).not.toHaveProperty('get');
|
||||
});
|
||||
|
||||
it('sets operationId to the tool name', () => {
|
||||
expect(result.paths['/tools/lookup-work-order']!.post.operationId).toBe(
|
||||
'lookup-work-order',
|
||||
);
|
||||
});
|
||||
|
||||
it('sets summary to the tool description', () => {
|
||||
expect(result.paths['/tools/lookup-work-order']!.post.summary).toBe(
|
||||
'Look up a work order by ID.',
|
||||
);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Tags and security
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('tags an ops tool with ["ops"]', () => {
|
||||
expect(result.paths['/tools/lookup-work-order']!.post.tags).toEqual(['ops']);
|
||||
});
|
||||
|
||||
it('tags a finance tool with ["finance"]', () => {
|
||||
expect(result.paths['/tools/search-vendors']!.post.tags).toEqual([
|
||||
'finance',
|
||||
]);
|
||||
});
|
||||
|
||||
it('adds bearerAuth security requirement to every operation', () => {
|
||||
const op = result.paths['/tools/lookup-work-order']!.post;
|
||||
expect(op.security).toEqual([{ bearerAuth: [] }]);
|
||||
});
|
||||
|
||||
it('sets x-required-scope extension from the tool definition', () => {
|
||||
expect(
|
||||
result.paths['/tools/lookup-work-order']!.post['x-required-scope'],
|
||||
).toBe('ops:read');
|
||||
expect(
|
||||
result.paths['/tools/search-vendors']!.post['x-required-scope'],
|
||||
).toBe('finance:read');
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Request body
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('marks requestBody as required', () => {
|
||||
const op = result.paths['/tools/lookup-work-order']!.post;
|
||||
expect(op.requestBody.required).toBe(true);
|
||||
});
|
||||
|
||||
it('uses application/json for the requestBody media type', () => {
|
||||
const op = result.paths['/tools/lookup-work-order']!.post;
|
||||
expect(op.requestBody.content).toHaveProperty('application/json');
|
||||
});
|
||||
|
||||
it('round-trips the tool inputSchema verbatim into the requestBody', () => {
|
||||
const op = result.paths['/tools/lookup-work-order']!.post;
|
||||
expect(op.requestBody.content['application/json'].schema).toEqual(
|
||||
lookupWorkOrder.inputSchema,
|
||||
);
|
||||
});
|
||||
|
||||
it('round-trips the finance tool inputSchema verbatim', () => {
|
||||
const op = result.paths['/tools/search-vendors']!.post;
|
||||
expect(op.requestBody.content['application/json'].schema).toEqual(
|
||||
searchVendors.inputSchema,
|
||||
);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Responses
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('includes a 200 response', () => {
|
||||
const responses = result.paths['/tools/lookup-work-order']!.post.responses;
|
||||
expect(responses).toHaveProperty('200');
|
||||
});
|
||||
|
||||
it('200 response has application/json content', () => {
|
||||
const r200 =
|
||||
result.paths['/tools/lookup-work-order']!.post.responses['200']!;
|
||||
expect(r200.content).toHaveProperty('application/json');
|
||||
});
|
||||
|
||||
it('includes a 403 response for scope errors', () => {
|
||||
const responses = result.paths['/tools/lookup-work-order']!.post.responses;
|
||||
expect(responses).toHaveProperty('403');
|
||||
});
|
||||
|
||||
it('403 response schema has requiredScope property', () => {
|
||||
const r403 =
|
||||
result.paths['/tools/lookup-work-order']!.post.responses['403']!;
|
||||
const schema = r403.content!['application/json'].schema as {
|
||||
properties: Record<string, unknown>;
|
||||
};
|
||||
expect(schema.properties).toHaveProperty('requiredScope');
|
||||
});
|
||||
|
||||
it('includes a 401 response for missing/invalid token', () => {
|
||||
const responses = result.paths['/tools/lookup-work-order']!.post.responses;
|
||||
expect(responses).toHaveProperty('401');
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Components / security schemes
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('includes a bearerAuth security scheme in components', () => {
|
||||
expect(result.components.securitySchemes).toHaveProperty('bearerAuth');
|
||||
expect(result.components.securitySchemes.bearerAuth.type).toBe('http');
|
||||
expect(result.components.securitySchemes.bearerAuth.scheme).toBe('bearer');
|
||||
expect(result.components.securitySchemes.bearerAuth.bearerFormat).toBe(
|
||||
'JWT',
|
||||
);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Empty registry
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('produces an empty paths object for an empty registry', () => {
|
||||
const empty = generateOpenAPIPaths(new ToolRegistry());
|
||||
expect(Object.keys(empty.paths)).toHaveLength(0);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Round-trip: registry → paths → verify all tools represented
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('round-trip: every registered tool appears exactly once in paths', () => {
|
||||
const tools = [
|
||||
defineTool({
|
||||
name: 'create-task',
|
||||
description: 'Create a task.',
|
||||
tier: 'ops',
|
||||
requiredScope: 'ops:tasks',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['title'],
|
||||
properties: { title: { type: 'string' } },
|
||||
},
|
||||
handler: async () => ({ id: 'T-1' }),
|
||||
}),
|
||||
defineTool({
|
||||
name: 'lookup-payment-by-vendor',
|
||||
description: 'Look up payments by vendor.',
|
||||
tier: 'finance',
|
||||
requiredScope: 'finance:read',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['vendorName'],
|
||||
properties: { vendorName: { type: 'string' } },
|
||||
},
|
||||
handler: async () => ({ payments: [] }),
|
||||
}),
|
||||
];
|
||||
const reg = buildRegistry(...tools);
|
||||
const out = generateOpenAPIPaths(reg);
|
||||
const paths = Object.keys(out.paths);
|
||||
expect(paths).toContain('/tools/create-task');
|
||||
expect(paths).toContain('/tools/lookup-payment-by-vendor');
|
||||
expect(paths).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ToolRegistry — defineTool and registry unit tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('ToolRegistry', () => {
|
||||
it('registers and lists a tool', () => {
|
||||
const reg = new ToolRegistry();
|
||||
reg.register(lookupWorkOrder);
|
||||
expect(reg.list()).toHaveLength(1);
|
||||
expect(reg.list()[0]!.name).toBe('lookup-work-order');
|
||||
});
|
||||
|
||||
it('get() returns a registered tool by name', () => {
|
||||
const reg = new ToolRegistry();
|
||||
reg.register(lookupWorkOrder);
|
||||
const found = reg.get('lookup-work-order');
|
||||
expect(found).toBeDefined();
|
||||
expect(found!.name).toBe('lookup-work-order');
|
||||
});
|
||||
|
||||
it('get() returns undefined for unknown tool', () => {
|
||||
const reg = new ToolRegistry();
|
||||
expect(reg.get('nonexistent')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws on duplicate tool name', () => {
|
||||
const reg = new ToolRegistry();
|
||||
reg.register(lookupWorkOrder);
|
||||
expect(() => reg.register(lookupWorkOrder)).toThrow(
|
||||
/duplicate tool name/,
|
||||
);
|
||||
});
|
||||
|
||||
it('register() is chainable', () => {
|
||||
const reg = new ToolRegistry();
|
||||
reg.register(lookupWorkOrder).register(searchVendors);
|
||||
expect(reg.size).toBe(2);
|
||||
});
|
||||
|
||||
it('size reflects the number of registered tools', () => {
|
||||
const reg = new ToolRegistry();
|
||||
expect(reg.size).toBe(0);
|
||||
reg.register(lookupWorkOrder);
|
||||
expect(reg.size).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('defineTool', () => {
|
||||
it('returns the definition unchanged', () => {
|
||||
const def = defineTool(lookupWorkOrder);
|
||||
expect(def).toBe(lookupWorkOrder);
|
||||
});
|
||||
});
|
||||
173
packages/shared/src/openapi.ts
Normal file
173
packages/shared/src/openapi.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
/**
|
||||
* OpenAPI 3.1 path generation from a ToolRegistry.
|
||||
*
|
||||
* Generates a `paths` object (one POST endpoint per tool) that can be merged
|
||||
* into a full OpenAPI document. Each endpoint:
|
||||
* - Accepts a JSON body matching the tool's `inputSchema`.
|
||||
* - Returns a 200 response with a generic `object` schema (tool output types
|
||||
* are not reflected here — that is a future enhancement once output schemas
|
||||
* are added to ToolDef).
|
||||
* - Returns a 403 response shape for scope errors.
|
||||
* - Is tagged with the tool's tier and annotated with the required scope as
|
||||
* an extension field (`x-required-scope`) so API Gateway authorizers and
|
||||
* docs consumers can read it.
|
||||
*
|
||||
* The transport (API Gateway, Lambda, etc.) is NOT wired here — this module
|
||||
* only generates the static schema object.
|
||||
*/
|
||||
|
||||
import type { ToolRegistry } from './registry.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OpenAPI 3.1 type stubs (subset used here; not pulling in a full OAS library)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface OASMediaType {
|
||||
schema: object;
|
||||
}
|
||||
|
||||
export interface OASRequestBody {
|
||||
required: boolean;
|
||||
content: {
|
||||
'application/json': OASMediaType;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OASResponse {
|
||||
description: string;
|
||||
content?: {
|
||||
'application/json': OASMediaType;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OASOperation {
|
||||
operationId: string;
|
||||
summary: string;
|
||||
description?: string;
|
||||
tags: string[];
|
||||
security: Array<{ bearerAuth: string[] }>;
|
||||
/** Extension: the Scope required to call this tool. */
|
||||
'x-required-scope': string;
|
||||
requestBody: OASRequestBody;
|
||||
responses: Record<string, OASResponse>;
|
||||
}
|
||||
|
||||
export interface OASPathItem {
|
||||
post: OASOperation;
|
||||
}
|
||||
|
||||
/**
|
||||
* A partial OpenAPI 3.1 document — just the `paths` and top-level `components`
|
||||
* that `generateOpenAPIPaths` produces. Callers merge this into their full doc.
|
||||
*/
|
||||
export interface OASPathsResult {
|
||||
paths: Record<string, OASPathItem>;
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http';
|
||||
scheme: 'bearer';
|
||||
bearerFormat: 'JWT';
|
||||
description: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Generate an OpenAPI 3.1 `paths` object from a `ToolRegistry`.
|
||||
*
|
||||
* Each tool produces one `POST /tools/{tool-name}` path.
|
||||
*
|
||||
* @param registry A populated ToolRegistry.
|
||||
* @returns A `{ paths, components }` object ready to be merged into a
|
||||
* full OpenAPI 3.1 document.
|
||||
*/
|
||||
export function generateOpenAPIPaths(registry: ToolRegistry): OASPathsResult {
|
||||
const paths: Record<string, OASPathItem> = {};
|
||||
|
||||
for (const tool of registry.list()) {
|
||||
const path = `/tools/${tool.name}`;
|
||||
|
||||
const operation: OASOperation = {
|
||||
operationId: tool.name,
|
||||
summary: tool.description,
|
||||
tags: [tool.tier],
|
||||
security: [{ bearerAuth: [] }],
|
||||
'x-required-scope': tool.requiredScope,
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: tool.inputSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Tool executed successfully.',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
description:
|
||||
'Tool-specific output. Schema varies per tool; consult tool documentation.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'403': {
|
||||
description:
|
||||
'The caller\'s token does not include the required scope for this tool.',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['error', 'requiredScope'],
|
||||
properties: {
|
||||
error: { type: 'string' },
|
||||
requiredScope: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'401': {
|
||||
description: 'Missing or invalid Authorization bearer token.',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['error'],
|
||||
properties: {
|
||||
error: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
paths[path] = { post: operation };
|
||||
}
|
||||
|
||||
return {
|
||||
paths,
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
description:
|
||||
'Cognito-issued JWT. Audience must match the target MCP server resource server identifier.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
252
packages/shared/src/redact.test.ts
Normal file
252
packages/shared/src/redact.test.ts
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { redact, REDACTED } from './redact.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SSN tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('redact — SSN', () => {
|
||||
it('masks canonical SSN (NNN-NN-NNNN)', () => {
|
||||
expect(redact('SSN: 123-45-6789')).toContain(REDACTED);
|
||||
expect(redact('SSN: 123-45-6789')).not.toContain('123-45-6789');
|
||||
});
|
||||
|
||||
it('masks spaced SSN (NNN NN NNNN)', () => {
|
||||
const result = redact('Social security: 123 45 6789');
|
||||
expect(result).toContain(REDACTED);
|
||||
expect(result).not.toContain('123 45 6789');
|
||||
});
|
||||
|
||||
it('masks bare 9-digit SSN after keyword', () => {
|
||||
const result = redact('ssn 123456789');
|
||||
expect(result).toContain(REDACTED);
|
||||
expect(result).not.toContain('123456789');
|
||||
});
|
||||
|
||||
it('masks SSN with "tax id" keyword', () => {
|
||||
const result = redact('tax id: 123-45-6789');
|
||||
expect(result).toContain(REDACTED);
|
||||
});
|
||||
|
||||
it('does NOT mask a bare 9-digit number without keyword', () => {
|
||||
// Could be an invoice number, PO number, etc.
|
||||
const result = redact('Invoice #987654321 is due.');
|
||||
expect(result).not.toContain(REDACTED);
|
||||
});
|
||||
|
||||
it('preserves surrounding text', () => {
|
||||
const result = redact('Employee 123-45-6789 is John Smith.');
|
||||
expect(result).toContain('Employee');
|
||||
expect(result).toContain('John Smith');
|
||||
expect(result).not.toContain('123-45-6789');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Routing number tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('redact — routing numbers', () => {
|
||||
it('masks routing number with keyword before', () => {
|
||||
const result = redact('routing number 021000021');
|
||||
expect(result).toContain(REDACTED);
|
||||
expect(result).not.toContain('021000021');
|
||||
});
|
||||
|
||||
it('masks ABA number with keyword', () => {
|
||||
const result = redact('ABA: 021000021');
|
||||
expect(result).toContain(REDACTED);
|
||||
expect(result).not.toContain('021000021');
|
||||
});
|
||||
|
||||
it('masks routing number with keyword after (parenthetical)', () => {
|
||||
const result = redact('021000021 (routing)');
|
||||
expect(result).toContain(REDACTED);
|
||||
expect(result).not.toContain('021000021');
|
||||
});
|
||||
|
||||
it('does NOT mask a bare 9-digit number that is not a routing number', () => {
|
||||
// Phone number fragment or zip+4 style — no keyword context
|
||||
const result = redact('Ref: 123456789 approved.');
|
||||
expect(result).not.toContain(REDACTED);
|
||||
});
|
||||
|
||||
it('preserves vendor name next to routing number', () => {
|
||||
const result = redact('Vendor: Acme Corp, routing number 021000021');
|
||||
expect(result).toContain('Acme Corp');
|
||||
expect(result).toContain(REDACTED);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Account number tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('redact — account numbers', () => {
|
||||
it('masks account number with keyword', () => {
|
||||
const result = redact('account number 12345678');
|
||||
expect(result).toContain(REDACTED);
|
||||
expect(result).not.toContain('12345678');
|
||||
});
|
||||
|
||||
it('masks acct # shorthand', () => {
|
||||
const result = redact('acct #98765');
|
||||
expect(result).toContain(REDACTED);
|
||||
expect(result).not.toContain('98765');
|
||||
});
|
||||
|
||||
it('masks account: prefix', () => {
|
||||
const result = redact('Account: 1234567890123');
|
||||
expect(result).toContain(REDACTED);
|
||||
});
|
||||
|
||||
it('does NOT mask invoice/PO numbers without account keyword', () => {
|
||||
const result = redact('Invoice #12345 for Acme Corp');
|
||||
expect(result).not.toContain(REDACTED);
|
||||
});
|
||||
|
||||
it('does NOT mask numbers below 4 digits', () => {
|
||||
const result = redact('account number 123');
|
||||
// 3 digits is below the minimum — no match
|
||||
expect(result).not.toContain(REDACTED);
|
||||
});
|
||||
|
||||
it('preserves contact info adjacent to account number', () => {
|
||||
const result = redact(
|
||||
'Contact billing@acme.com for account number 987654321',
|
||||
);
|
||||
expect(result).toContain('billing@acme.com');
|
||||
expect(result).toContain(REDACTED);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Card number tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('redact — card numbers', () => {
|
||||
it('masks a valid 16-digit Visa card number', () => {
|
||||
// 4111111111111111 is the canonical Luhn-valid test card
|
||||
const result = redact('Card: 4111111111111111');
|
||||
expect(result).toContain(REDACTED);
|
||||
expect(result).not.toContain('4111111111111111');
|
||||
});
|
||||
|
||||
it('masks a hyphen-separated card number', () => {
|
||||
const result = redact('Card: 4111-1111-1111-1111');
|
||||
expect(result).toContain(REDACTED);
|
||||
});
|
||||
|
||||
it('masks a space-separated card number', () => {
|
||||
const result = redact('Card: 4111 1111 1111 1111');
|
||||
expect(result).toContain(REDACTED);
|
||||
});
|
||||
|
||||
it('masks a valid 15-digit Amex number', () => {
|
||||
// 378282246310005 is the canonical Amex test card
|
||||
const result = redact('Amex: 378282246310005');
|
||||
expect(result).toContain(REDACTED);
|
||||
});
|
||||
|
||||
it('does NOT mask a 16-digit number that fails Luhn', () => {
|
||||
// 1234567890123456 fails Luhn
|
||||
const result = redact('Value: 1234567890123456');
|
||||
expect(result).not.toContain(REDACTED);
|
||||
});
|
||||
|
||||
it('does NOT mask phone numbers (10 digits, fail Luhn)', () => {
|
||||
const result = redact('Call us at 5551234567');
|
||||
// 10 digits is below the 13-digit minimum for card pattern
|
||||
expect(result).not.toContain(REDACTED);
|
||||
});
|
||||
|
||||
it('preserves vendor name next to card number', () => {
|
||||
const result = redact('Charged Stripe account for 4111111111111111');
|
||||
expect(result).toContain('Stripe');
|
||||
expect(result).toContain(REDACTED);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vendor names and contacts left intact
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('redact — preserves vendor names and contacts', () => {
|
||||
it('leaves company names intact', () => {
|
||||
const result = redact('Vendor: Consolidated Edison Co.');
|
||||
expect(result).toBe('Vendor: Consolidated Edison Co.');
|
||||
});
|
||||
|
||||
it('leaves email addresses intact', () => {
|
||||
const result = redact('Contact: billing@acmecorp.com');
|
||||
expect(result).toBe('Contact: billing@acmecorp.com');
|
||||
});
|
||||
|
||||
it('leaves phone numbers intact', () => {
|
||||
// Standard US phone — 10 digits, below card threshold
|
||||
const result = redact('Phone: 212-555-0100');
|
||||
expect(result).toBe('Phone: 212-555-0100');
|
||||
});
|
||||
|
||||
it('leaves street addresses intact', () => {
|
||||
const result = redact('123 Main Street, New York, NY 10001');
|
||||
expect(result).toBe('123 Main Street, New York, NY 10001');
|
||||
});
|
||||
|
||||
it('leaves dollar amounts intact', () => {
|
||||
const result = redact('Invoice total: $1,234.56');
|
||||
expect(result).toBe('Invoice total: $1,234.56');
|
||||
});
|
||||
|
||||
it('leaves invoice and PO numbers intact', () => {
|
||||
const result = redact('PO #20240001, Invoice #INV-9999');
|
||||
expect(result).toBe('PO #20240001, Invoice #INV-9999');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mixed / realistic finance response
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('redact — realistic finance responses', () => {
|
||||
it('masks multiple sensitive fields in a payment record', () => {
|
||||
const payload = [
|
||||
'Vendor: Acme Supply Co.',
|
||||
'Contact: accounts@acme.com',
|
||||
'Bank routing: 021000021',
|
||||
'Account number: 123456789012',
|
||||
'Check amount: $4,500.00',
|
||||
'Employee SSN: 123-45-6789',
|
||||
].join('\n');
|
||||
|
||||
const result = redact(payload);
|
||||
|
||||
// Sensitive fields masked
|
||||
expect(result).not.toContain('021000021');
|
||||
expect(result).not.toContain('123456789012');
|
||||
expect(result).not.toContain('123-45-6789');
|
||||
|
||||
// Vendor name and contact intact
|
||||
expect(result).toContain('Acme Supply Co.');
|
||||
expect(result).toContain('accounts@acme.com');
|
||||
|
||||
// Dollar amount intact
|
||||
expect(result).toContain('$4,500.00');
|
||||
});
|
||||
|
||||
it('is idempotent — redacting twice produces the same result', () => {
|
||||
const payload = 'SSN: 987-65-4321, routing number 021000021';
|
||||
const once = redact(payload);
|
||||
const twice = redact(once);
|
||||
expect(once).toBe(twice);
|
||||
});
|
||||
|
||||
it('handles empty string without error', () => {
|
||||
expect(redact('')).toBe('');
|
||||
});
|
||||
|
||||
it('handles string with no sensitive data unchanged', () => {
|
||||
const clean = 'Lookup result: work order WO-12345 for site Central Park.';
|
||||
expect(redact(clean)).toBe(clean);
|
||||
});
|
||||
});
|
||||
173
packages/shared/src/redact.ts
Normal file
173
packages/shared/src/redact.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
/**
|
||||
* MCP-layer PII redaction.
|
||||
*
|
||||
* Masks sensitive financial identifiers in tool response strings before they
|
||||
* leave the server and reach the LLM agent or the user.
|
||||
*
|
||||
* WHAT IS MASKED:
|
||||
* - US bank routing numbers (9 digits, ABA)
|
||||
* - US bank account numbers (4–17 digits following routing or account keywords)
|
||||
* - Payment card numbers (13–19 digits, Luhn-matching encouraged; basic pattern here)
|
||||
* - US Social Security Numbers (NNN-NN-NNNN / NNN NN NNNN / NNNNNNNNN)
|
||||
*
|
||||
* WHAT IS LEFT INTACT:
|
||||
* - Vendor/company names
|
||||
* - Contact information (phone numbers, email addresses, street addresses)
|
||||
* - Dollar amounts and invoice/PO numbers
|
||||
* - Any other non-financial-identity data
|
||||
*
|
||||
* See docs/design.md §2.5 for the requirement context.
|
||||
* See src/redact.test.ts for the full contract.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mask helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** The string used to replace masked values. */
|
||||
export const REDACTED = '[REDACTED]';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Individual pattern redactors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Mask US Social Security Numbers.
|
||||
*
|
||||
* Patterns matched:
|
||||
* - NNN-NN-NNNN (canonical)
|
||||
* - NNN NN NNNN (spaced)
|
||||
* - NNNNNNNNN (bare 9 digits) — only when preceded by an SSN keyword
|
||||
* to avoid colliding with routing/account numbers handled below.
|
||||
*/
|
||||
function redactSSN(value: string): string {
|
||||
// Canonical and spaced formats (unambiguous)
|
||||
let result = value.replace(
|
||||
/\b(\d{3})[- ](\d{2})[- ](\d{4})\b/g,
|
||||
REDACTED,
|
||||
);
|
||||
// Bare 9-digit SSN preceded by an SSN keyword
|
||||
result = result.replace(
|
||||
/\b(ssn|social\s+security(?:\s+number)?|tax\s+id)\s*[:#]?\s*(\d{9})\b/gi,
|
||||
(_match, keyword) => `${keyword} ${REDACTED}`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask US ABA routing numbers.
|
||||
*
|
||||
* ABA routing numbers are exactly 9 digits. We match them when:
|
||||
* a) preceded by a routing-number keyword, OR
|
||||
* b) followed by a routing-number keyword
|
||||
*
|
||||
* We do NOT mask bare 9-digit strings without a keyword to avoid clobbering
|
||||
* zip+4 combos, phone fragments, etc.
|
||||
*/
|
||||
function redactRouting(value: string): string {
|
||||
// Keyword BEFORE the number: "routing number: 021000021"
|
||||
let result = value.replace(
|
||||
/\b(routing\s*(?:number|#|no\.?)?|aba\s*(?:number|#|no\.?)?)\s*[:#]?\s*(\d{9})\b/gi,
|
||||
(_match, keyword) => `${keyword.trim()} ${REDACTED}`,
|
||||
);
|
||||
// Keyword AFTER the number: "021000021 (routing)"
|
||||
result = result.replace(
|
||||
/\b(\d{9})\s*\((routing|aba)\)/gi,
|
||||
`${REDACTED} ($2)`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask US bank account numbers.
|
||||
*
|
||||
* Bank account numbers are 4–17 digits. We mask them only when a keyword
|
||||
* context makes them unambiguous, to avoid clobbering invoice/PO numbers,
|
||||
* phone numbers, etc.
|
||||
*/
|
||||
function redactAccountNumber(value: string): string {
|
||||
return value.replace(
|
||||
/\b(account\s*(?:number|#|no\.?)?|acct\.?\s*(?:#|no\.?)?)\s*[:#]?\s*(\d{4,17})\b/gi,
|
||||
(_match, keyword) => `${keyword.trim()} ${REDACTED}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask payment card numbers (credit / debit).
|
||||
*
|
||||
* Matches 13–19 consecutive digits (with optional spaces or hyphens between
|
||||
* groups of 4) that look like a PAN. We apply a basic Luhn check so common
|
||||
* non-card numeric strings (invoice IDs, phone numbers) do not get masked.
|
||||
*/
|
||||
function passesLuhn(digits: string): boolean {
|
||||
let sum = 0;
|
||||
let alternate = false;
|
||||
for (let i = digits.length - 1; i >= 0; i--) {
|
||||
let n = parseInt(digits[i]!, 10);
|
||||
if (alternate) {
|
||||
n *= 2;
|
||||
if (n > 9) n -= 9;
|
||||
}
|
||||
sum += n;
|
||||
alternate = !alternate;
|
||||
}
|
||||
return sum % 10 === 0;
|
||||
}
|
||||
|
||||
function redactCard(value: string): string {
|
||||
// Match 13–19 digits, optionally separated by spaces or hyphens in groups of 4.
|
||||
return value.replace(
|
||||
/\b(\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{1,7}|\d{13,19})\b/g,
|
||||
(match) => {
|
||||
const digits = match.replace(/[\s-]/g, '');
|
||||
if (digits.length < 13 || digits.length > 19) return match;
|
||||
if (!passesLuhn(digits)) return match;
|
||||
return REDACTED;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Redact PII from a string that may appear in a tool response.
|
||||
*
|
||||
* Applies all pattern redactors in a safe order (SSN first to avoid the bare
|
||||
* 9-digit pattern conflicting with the routing-number check).
|
||||
*
|
||||
* The function is PURE and has no side effects. It never makes network calls.
|
||||
*
|
||||
* @param value The raw string (may be JSON, plain text, CSV, etc.)
|
||||
* @returns A copy of `value` with sensitive fields replaced by `[REDACTED]`.
|
||||
*/
|
||||
export function redact(value: string): string {
|
||||
let result = value;
|
||||
result = redactSSN(result);
|
||||
result = redactRouting(result);
|
||||
result = redactAccountNumber(result);
|
||||
result = redactCard(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask a sensitive field value at the field level.
|
||||
*
|
||||
* Use this when you have an already-isolated sensitive field value (e.g. a
|
||||
* bank account number stored in its own database column) and need to produce
|
||||
* a display-safe string. Unlike `redact()`, which is designed for inline
|
||||
* pattern-matching inside free-form text, `maskValue` blindly replaces the
|
||||
* entire value with masking characters.
|
||||
*
|
||||
* Finance-tier tools MUST still call `redact()` on the field as well —
|
||||
* `maskValue` is a complementary, not a replacement, operation.
|
||||
*
|
||||
* @param value The raw sensitive string (e.g. "123456789", "4111-1111-1111-1111").
|
||||
* @returns A string of `●` characters the same length as `value` (max 16),
|
||||
* safe to include in tool output or logs.
|
||||
*/
|
||||
export function maskValue(value: string): string {
|
||||
// Show at most 16 mask characters so excessively long values don't bloat output.
|
||||
return '●'.repeat(Math.min(value.length, 16));
|
||||
}
|
||||
89
packages/shared/src/registry.ts
Normal file
89
packages/shared/src/registry.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
/**
|
||||
* In-memory tool registry.
|
||||
*
|
||||
* Packages call `defineTool()` to create a typed ToolDef, then
|
||||
* `ToolRegistry.register()` to publish it. Transport adapters (OpenAPI,
|
||||
* MCP) consume the registry via `list()` / `get()`.
|
||||
*
|
||||
* There is no global singleton registry exported here — each server
|
||||
* instantiates its own registry so tests stay isolated.
|
||||
*/
|
||||
|
||||
import type { ToolDef } from './types.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// defineTool — identity helper that preserves full generic types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Wrap a tool definition to get TypeScript inference on `I` and `O` while
|
||||
* returning a value that satisfies `ToolDef<I, O>`.
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* export const searchInbox = defineTool({
|
||||
* name: 'search-inbox',
|
||||
* tier: 'ops',
|
||||
* requiredScope: 'gmail:self',
|
||||
* // ...
|
||||
* handler: async (input: SearchInboxInput, ctx) => { ... },
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function defineTool<I, O>(def: ToolDef<I, O>): ToolDef<I, O> {
|
||||
return def;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ToolRegistry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A typed, in-memory registry of tool definitions.
|
||||
*
|
||||
* Tool names must be unique across a registry instance. Attempting to
|
||||
* register a duplicate name throws synchronously so misconfigurations are
|
||||
* caught at server startup, not at request time.
|
||||
*/
|
||||
export class ToolRegistry {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private readonly tools = new Map<string, ToolDef<any, any>>();
|
||||
|
||||
/**
|
||||
* Register a tool. Throws if a tool with the same name is already registered.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
register<I, O>(def: ToolDef<I, O>): this {
|
||||
if (this.tools.has(def.name)) {
|
||||
throw new Error(
|
||||
`ToolRegistry: duplicate tool name "${def.name}". ` +
|
||||
'Each tool name must be unique within a registry.',
|
||||
);
|
||||
}
|
||||
this.tools.set(def.name, def);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all registered tool definitions in insertion order.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
list(): ToolDef<any, any>[] {
|
||||
return [...this.tools.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a tool by name. Returns `undefined` if not found.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
get(name: string): ToolDef<any, any> | undefined {
|
||||
return this.tools.get(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* The number of tools currently registered.
|
||||
*/
|
||||
get size(): number {
|
||||
return this.tools.size;
|
||||
}
|
||||
}
|
||||
101
packages/shared/src/types.ts
Normal file
101
packages/shared/src/types.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
/**
|
||||
* Core type definitions for the sh-mcp platform.
|
||||
*
|
||||
* Every package defines tools AGAINST these types. Do NOT reimplement them.
|
||||
* The wire transport (OpenAPI now, MCP later) is generated from the registry;
|
||||
* packages only define tools.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scopes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* All authorization scopes the platform can issue.
|
||||
*
|
||||
* Mapping is: Google Group → Cognito group → scope claims in the JWT.
|
||||
* The full group→scope matrix is documented in docs/design.md §2.3.
|
||||
*/
|
||||
export type Scope =
|
||||
| 'ops:read'
|
||||
| 'ops:tasks'
|
||||
| 'gmail:self'
|
||||
| 'calendar:self'
|
||||
| 'finance:read'
|
||||
| 'finance:admin';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth context
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Decoded, validated claims extracted from the inbound Cognito JWT.
|
||||
*
|
||||
* Populated by the DEFERRED 0a-gated auth layer (see src/auth.ts TODO).
|
||||
* In tests, pass a mock object directly.
|
||||
*
|
||||
* Fields match the token claims documented in docs/design.md §2.2:
|
||||
* sub – Google-federated user identity (e.g. "lauren@seahavenind.com")
|
||||
* aud – Audience that the token was minted for (e.g. "sh-mcp-ops")
|
||||
* scopes – Union of scopes granted to this user via group membership
|
||||
*/
|
||||
export interface AuthContext {
|
||||
/** The user's Google-federated identity (Cognito `sub`). */
|
||||
sub: string;
|
||||
/** Scopes granted to this user for this token. */
|
||||
scopes: Scope[];
|
||||
/**
|
||||
* Audience claim from the JWT — must match the target server's resource server identifier.
|
||||
* Validated by the server; an ops token presented to finance MUST be rejected.
|
||||
*/
|
||||
aud: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON Schema alias
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A JSON Schema object (Draft 7 / OpenAPI 3.1 subset).
|
||||
* Using `object` keeps the type simple while allowing any valid schema shape.
|
||||
* Callers should use a schema-builder or inline literal objects.
|
||||
*/
|
||||
export type JSONSchema = object;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool definition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The canonical shape of a Sea Haven MCP tool.
|
||||
*
|
||||
* - `I` – the TypeScript type of the validated input object.
|
||||
* - `O` – the TypeScript type of the value the handler resolves with.
|
||||
*
|
||||
* Tools are registered in a ToolRegistry and are never called directly by
|
||||
* transport code; the registry drives both the MCP server and the OpenAPI
|
||||
* path generator.
|
||||
*/
|
||||
export interface ToolDef<I, O> {
|
||||
/** Unique, kebab-case tool name (e.g. "search-inbox"). */
|
||||
name: string;
|
||||
/** Human-readable description surfaced to the LLM agent. */
|
||||
description: string;
|
||||
/** Trust tier — determines which MCP server hosts this tool. */
|
||||
tier: 'ops' | 'finance';
|
||||
/** The single scope that must be present in the caller's AuthContext. */
|
||||
requiredScope: Scope;
|
||||
/**
|
||||
* JSON Schema for the tool's input object.
|
||||
* Used for OpenAPI requestBody generation and MCP tool-list exposure.
|
||||
*/
|
||||
inputSchema: JSONSchema;
|
||||
/**
|
||||
* The tool implementation.
|
||||
*
|
||||
* Implementors MUST call `requireScope(ctx, def.requiredScope)` at the top
|
||||
* of every handler (or rely on the registry dispatcher to do it). The
|
||||
* handler should never access external AWS services at import time.
|
||||
*/
|
||||
handler: (input: I, ctx: AuthContext) => Promise<O>;
|
||||
}
|
||||
9
packages/shared/tsconfig.json
Normal file
9
packages/shared/tsconfig.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"declarationDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
30
packages/tasks/package.json
Normal file
30
packages/tasks/package.json
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"name": "@sh-mcp/tasks",
|
||||
"version": "0.1.0",
|
||||
"description": "Sea Haven MCP — ops-tier task tools (create/list/complete/delete)",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --project tsconfig.json",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sh-mcp/shared": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^2.0.0",
|
||||
"typescript": "^5.5.0"
|
||||
}
|
||||
}
|
||||
124
packages/tasks/src/client.ts
Normal file
124
packages/tasks/src/client.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
/**
|
||||
* TasksClient — external-dependency interface for the tasks package.
|
||||
*
|
||||
* All code in tools.ts codes against the TasksClient interface, never against a
|
||||
* concrete AWS SDK import. The real implementation (DynamoDBTasksClient) stubs
|
||||
* the actual DynamoDB call so no AWS credentials or network are needed at import
|
||||
* time or in tests.
|
||||
*
|
||||
* Tests inject a MockTasksClient (see test/tasks.test.ts).
|
||||
*/
|
||||
|
||||
export interface Task {
|
||||
taskId: string;
|
||||
sub: string; // owner — partition key, enforced ABAC (dynamodb:LeadingKeys)
|
||||
title: string;
|
||||
description?: string;
|
||||
completed: boolean;
|
||||
createdAt: string; // ISO-8601
|
||||
completedAt?: string; // ISO-8601
|
||||
}
|
||||
|
||||
export interface CreateTaskInput {
|
||||
sub: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ListTasksInput {
|
||||
sub: string;
|
||||
includeCompleted?: boolean;
|
||||
}
|
||||
|
||||
export interface CompleteTaskInput {
|
||||
sub: string;
|
||||
taskId: string;
|
||||
}
|
||||
|
||||
export interface DeleteTaskInput {
|
||||
sub: string;
|
||||
taskId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The interface every caller (tools.ts, jobs, tests) depends on.
|
||||
* The DynamoDB table is partitioned by `sub`; callers always pass their own sub
|
||||
* so the ABAC LeadingKeys condition on the IAM policy matches.
|
||||
*/
|
||||
export interface TasksClient {
|
||||
createTask(input: CreateTaskInput): Promise<Task>;
|
||||
listTasks(input: ListTasksInput): Promise<Task[]>;
|
||||
completeTask(input: CompleteTaskInput): Promise<Task>;
|
||||
deleteTask(input: DeleteTaskInput): Promise<void>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Real (DynamoDB) implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
// The real AWS SDK import is lazy and guard-wrapped so that:
|
||||
// 1. Importing this file at test time does NOT instantiate a real SDK client.
|
||||
// 2. A real deployment provides TABLE_NAME and AWS credentials via the
|
||||
// Lambda execution environment.
|
||||
//
|
||||
// TODO (DEFERRED — auth layer): Once the real JWT/aud/client_id validation
|
||||
// layer is in place, ensure the DynamoDB client is constructed with a role that
|
||||
// only has `dynamodb:GetItem`, `dynamodb:PutItem`, `dynamodb:UpdateItem`,
|
||||
// `dynamodb:DeleteItem`, `dynamodb:Query` on the tasks table, scoped to
|
||||
// `dynamodb:LeadingKeys` = `${cognito-identity.amazonaws.com:sub}` so a
|
||||
// compromised server cannot read another user's tasks.
|
||||
|
||||
const TABLE_NAME = process.env['TASKS_TABLE_NAME'] ?? 'sh-mcp-tasks';
|
||||
|
||||
export class DynamoDBTasksClient implements TasksClient {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private ddb: any; // typed as `any` to avoid importing @aws-sdk/client-dynamodb at the top level
|
||||
|
||||
constructor() {
|
||||
if (process.env['NODE_ENV'] === 'test') {
|
||||
throw new Error(
|
||||
'DynamoDBTasksClient must not be instantiated in tests. Inject a mock TasksClient instead.',
|
||||
);
|
||||
}
|
||||
// Lazy import — only reached in a real Lambda execution environment.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { DynamoDBClient } = require('@aws-sdk/client-dynamodb');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { DynamoDBDocumentClient } = require('@aws-sdk/lib-dynamodb');
|
||||
this.ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
|
||||
// Accessed once so TypeScript does not flag the field as write-only.
|
||||
// Remove when the real DynamoDB calls are wired in the methods below.
|
||||
void this.ddb;
|
||||
}
|
||||
|
||||
async createTask(input: CreateTaskInput): Promise<Task> {
|
||||
// TODO: replace this stub with a real `PutCommand` against TABLE_NAME.
|
||||
// Stub guards against accidental real calls during development.
|
||||
throw new Error(
|
||||
`DynamoDBTasksClient.createTask not yet implemented. Table: ${TABLE_NAME}, input: ${JSON.stringify(input)}`,
|
||||
);
|
||||
}
|
||||
|
||||
async listTasks(input: ListTasksInput): Promise<Task[]> {
|
||||
// TODO: replace with a real `QueryCommand` (KeyConditionExpression: 'sub = :sub',
|
||||
// optionally FilterExpression: 'completed = :completed').
|
||||
throw new Error(
|
||||
`DynamoDBTasksClient.listTasks not yet implemented. Table: ${TABLE_NAME}, input: ${JSON.stringify(input)}`,
|
||||
);
|
||||
}
|
||||
|
||||
async completeTask(input: CompleteTaskInput): Promise<Task> {
|
||||
// TODO: replace with a real `UpdateCommand` setting completed = true, completedAt = now.
|
||||
// Enforce ownership: ConditionExpression: 'sub = :sub' so a user cannot complete another
|
||||
// user's task even if they guess the taskId.
|
||||
throw new Error(
|
||||
`DynamoDBTasksClient.completeTask not yet implemented. Table: ${TABLE_NAME}, input: ${JSON.stringify(input)}`,
|
||||
);
|
||||
}
|
||||
|
||||
async deleteTask(input: DeleteTaskInput): Promise<void> {
|
||||
// TODO: replace with a real `DeleteCommand` with the same sub-ownership condition.
|
||||
throw new Error(
|
||||
`DynamoDBTasksClient.deleteTask not yet implemented. Table: ${TABLE_NAME}, input: ${JSON.stringify(input)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
33
packages/tasks/src/index.ts
Normal file
33
packages/tasks/src/index.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
/**
|
||||
* @sh-mcp/tasks — public entry point.
|
||||
*
|
||||
* Exports the tool definitions built against a DynamoDBTasksClient.
|
||||
* The MCP server imports `tools` (the live array) and the tool factory
|
||||
* `buildTaskTools` for cases where an alternate client must be injected
|
||||
* (e.g. local dev, custom test harnesses).
|
||||
*
|
||||
* Wire transport (OpenAPI / Streamable-HTTP MCP) is generated from the tool
|
||||
* registry in packages/shared; this package only defines the tools.
|
||||
*/
|
||||
|
||||
export { buildTaskTools } from './tools.js';
|
||||
export type { TasksClient, Task, CreateTaskInput, ListTasksInput, CompleteTaskInput, DeleteTaskInput } from './client.js';
|
||||
export { DynamoDBTasksClient } from './client.js';
|
||||
|
||||
// The `tools` export is the live array used by the MCP server at runtime.
|
||||
// It is constructed with the real DynamoDB client, which guard-throws in test
|
||||
// environments to ensure tests always go through buildTaskTools(mockClient).
|
||||
import { buildTaskTools } from './tools.js';
|
||||
import { DynamoDBTasksClient } from './client.js';
|
||||
|
||||
// Only instantiate the real client outside of test environments.
|
||||
// In test environments, tests import buildTaskTools directly and inject a mock.
|
||||
const _client =
|
||||
process.env['NODE_ENV'] === 'test'
|
||||
? null
|
||||
: new DynamoDBTasksClient();
|
||||
|
||||
export const tools =
|
||||
_client !== null
|
||||
? buildTaskTools(_client)
|
||||
: ([] as unknown as ReturnType<typeof buildTaskTools>);
|
||||
187
packages/tasks/src/tools.ts
Normal file
187
packages/tasks/src/tools.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
/**
|
||||
* Task tools — ops tier, scope: ops:tasks
|
||||
*
|
||||
* All four tools (create_task, list_tasks, complete_task, delete_task) are
|
||||
* scoped to ops:tasks and operate on the calling user's tasks only (ABAC:
|
||||
* partition key = ctx.sub). The injected TasksClient is the only I/O path;
|
||||
* no AWS SDK or network call is made directly here.
|
||||
*
|
||||
* Finance-tier note: this is an ops-tier package. No finance fields are
|
||||
* present, so redact() is not called here. If this package is ever promoted
|
||||
* or a finance field is added, every sensitive field MUST be wrapped in
|
||||
* redact() before it is included in the tool output.
|
||||
*/
|
||||
|
||||
import { defineTool, requireScope, type AuthContext } from '@sh-mcp/shared';
|
||||
import type { TasksClient } from './client.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool factory — accepts an injected TasksClient so tests can pass a mock.
|
||||
// The MCP server entry point calls buildTaskTools(new DynamoDBTasksClient()).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function buildTaskTools(client: TasksClient) {
|
||||
// -------------------------------------------------------------------------
|
||||
// create_task
|
||||
// -------------------------------------------------------------------------
|
||||
const createTask = defineTool<
|
||||
{ title: string; description?: string },
|
||||
{ task: { taskId: string; title: string; description?: string; completed: boolean; createdAt: string } }
|
||||
>({
|
||||
name: 'create_task',
|
||||
description:
|
||||
'Create a new task for the calling user. Tasks are private — only the user who created a task can see or modify it.',
|
||||
tier: 'ops',
|
||||
requiredScope: 'ops:tasks',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
title: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
maxLength: 256,
|
||||
description: 'Short title for the task (required).',
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
maxLength: 2048,
|
||||
description: 'Optional longer description or notes for the task.',
|
||||
},
|
||||
},
|
||||
required: ['title'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
handler: async (input, ctx: AuthContext) => {
|
||||
requireScope(ctx, 'ops:tasks');
|
||||
const task = await client.createTask({
|
||||
sub: ctx.sub,
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
});
|
||||
return {
|
||||
task: {
|
||||
taskId: task.taskId,
|
||||
title: task.title,
|
||||
description: task.description,
|
||||
completed: task.completed,
|
||||
createdAt: task.createdAt,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// list_tasks
|
||||
// -------------------------------------------------------------------------
|
||||
const listTasks = defineTool<
|
||||
{ includeCompleted?: boolean },
|
||||
{ tasks: Array<{ taskId: string; title: string; description?: string; completed: boolean; createdAt: string; completedAt?: string }> }
|
||||
>({
|
||||
name: 'list_tasks',
|
||||
description:
|
||||
'List tasks belonging to the calling user. By default only incomplete tasks are returned; pass includeCompleted: true to see all.',
|
||||
tier: 'ops',
|
||||
requiredScope: 'ops:tasks',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
includeCompleted: {
|
||||
type: 'boolean',
|
||||
description: 'When true, completed tasks are included in the results. Defaults to false.',
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
handler: async (input, ctx: AuthContext) => {
|
||||
requireScope(ctx, 'ops:tasks');
|
||||
const tasks = await client.listTasks({
|
||||
sub: ctx.sub,
|
||||
includeCompleted: input.includeCompleted ?? false,
|
||||
});
|
||||
return {
|
||||
tasks: tasks.map((t) => ({
|
||||
taskId: t.taskId,
|
||||
title: t.title,
|
||||
description: t.description,
|
||||
completed: t.completed,
|
||||
createdAt: t.createdAt,
|
||||
completedAt: t.completedAt,
|
||||
})),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// complete_task
|
||||
// -------------------------------------------------------------------------
|
||||
const completeTask = defineTool<
|
||||
{ taskId: string },
|
||||
{ task: { taskId: string; title: string; completed: boolean; completedAt: string } }
|
||||
>({
|
||||
name: 'complete_task',
|
||||
description:
|
||||
"Mark a task as completed. The task must belong to the calling user; completing another user's task is not permitted.",
|
||||
tier: 'ops',
|
||||
requiredScope: 'ops:tasks',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
taskId: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
description: 'The ID of the task to mark as completed.',
|
||||
},
|
||||
},
|
||||
required: ['taskId'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
handler: async (input, ctx: AuthContext) => {
|
||||
requireScope(ctx, 'ops:tasks');
|
||||
const task = await client.completeTask({
|
||||
sub: ctx.sub,
|
||||
taskId: input.taskId,
|
||||
});
|
||||
return {
|
||||
task: {
|
||||
taskId: task.taskId,
|
||||
title: task.title,
|
||||
completed: task.completed,
|
||||
completedAt: task.completedAt as string,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// delete_task
|
||||
// -------------------------------------------------------------------------
|
||||
const deleteTask = defineTool<{ taskId: string }, { deleted: true; taskId: string }>({
|
||||
name: 'delete_task',
|
||||
description:
|
||||
'Permanently delete a task belonging to the calling user. This action is irreversible.',
|
||||
tier: 'ops',
|
||||
requiredScope: 'ops:tasks',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
taskId: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
description: 'The ID of the task to delete.',
|
||||
},
|
||||
},
|
||||
required: ['taskId'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
handler: async (input, ctx: AuthContext) => {
|
||||
requireScope(ctx, 'ops:tasks');
|
||||
await client.deleteTask({
|
||||
sub: ctx.sub,
|
||||
taskId: input.taskId,
|
||||
});
|
||||
return { deleted: true, taskId: input.taskId };
|
||||
},
|
||||
});
|
||||
|
||||
return [createTask, listTasks, completeTask, deleteTask] as const;
|
||||
}
|
||||
379
packages/tasks/test/tasks.test.ts
Normal file
379
packages/tasks/test/tasks.test.ts
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
/**
|
||||
* @sh-mcp/tasks — unit tests
|
||||
*
|
||||
* All tests use:
|
||||
* - A mock AuthContext (no real JWT; the auth layer is DEFERRED — see TODO
|
||||
* in src/client.ts). requireScope() from @sh-mcp/shared is exercised as
|
||||
* the live implementation so that scope-guard behaviour is tested.
|
||||
* - A mock TasksClient (no DynamoDB, no AWS credentials, no network).
|
||||
*
|
||||
* Coverage targets:
|
||||
* - Happy path for each of the four tools.
|
||||
* - Empty result from listTasks.
|
||||
* - Client error propagation (the tool must not swallow errors).
|
||||
* - Throttle / transient-error retry surface (the tool propagates the error
|
||||
* upward; retry policy lives at the transport layer, not in tool handlers).
|
||||
* - Scope guard: a caller without ops:tasks is rejected before the client
|
||||
* is ever called.
|
||||
* - ABAC isolation: the client always receives ctx.sub, not an override from
|
||||
* the input payload.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { buildTaskTools } from '../src/tools.js';
|
||||
import type { TasksClient, Task } from '../src/client.js';
|
||||
import type { AuthContext } from '@sh-mcp/shared';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MOCK_CTX: AuthContext = {
|
||||
sub: 'lauren@seahavenind.com',
|
||||
scopes: ['ops:read', 'ops:tasks'],
|
||||
aud: 'sh-mcp-ops',
|
||||
};
|
||||
|
||||
const CTX_NO_TASKS: AuthContext = {
|
||||
sub: 'staff@seahavenind.com',
|
||||
scopes: ['ops:read'], // no ops:tasks
|
||||
aud: 'sh-mcp-ops',
|
||||
};
|
||||
|
||||
const TASK_1: Task = {
|
||||
taskId: 'task-001',
|
||||
sub: 'lauren@seahavenind.com',
|
||||
title: 'Review vendor invoices',
|
||||
description: 'Check against PO log before EOD',
|
||||
completed: false,
|
||||
createdAt: '2026-06-11T09:00:00.000Z',
|
||||
};
|
||||
|
||||
const TASK_1_COMPLETED: Task = {
|
||||
...TASK_1,
|
||||
completed: true,
|
||||
completedAt: '2026-06-11T10:00:00.000Z',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock client factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeMockClient(overrides: Partial<TasksClient> = {}): TasksClient {
|
||||
return {
|
||||
createTask: vi.fn().mockResolvedValue(TASK_1),
|
||||
listTasks: vi.fn().mockResolvedValue([TASK_1]),
|
||||
completeTask: vi.fn().mockResolvedValue(TASK_1_COMPLETED),
|
||||
deleteTask: vi.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getHandler(tools: ReturnType<typeof buildTaskTools>, name: string) {
|
||||
const tool = tools.find((t) => t.name === name);
|
||||
if (!tool) throw new Error(`Tool '${name}' not found`);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (input: any, ctx: AuthContext) => tool.handler(input, ctx);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// create_task
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('create_task', () => {
|
||||
let client: TasksClient;
|
||||
let tools: ReturnType<typeof buildTaskTools>;
|
||||
|
||||
beforeEach(() => {
|
||||
client = makeMockClient();
|
||||
tools = buildTaskTools(client);
|
||||
});
|
||||
|
||||
it('happy path: creates a task and returns the expected shape', async () => {
|
||||
const call = getHandler(tools, 'create_task');
|
||||
const result = await call({ title: 'Review vendor invoices', description: 'Check against PO log' }, MOCK_CTX);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
task: {
|
||||
taskId: 'task-001',
|
||||
title: 'Review vendor invoices',
|
||||
completed: false,
|
||||
createdAt: '2026-06-11T09:00:00.000Z',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('passes ctx.sub as the owner, not any caller-supplied override', async () => {
|
||||
const call = getHandler(tools, 'create_task');
|
||||
await call({ title: 'Test ABAC' }, MOCK_CTX);
|
||||
|
||||
expect(client.createTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sub: MOCK_CTX.sub }),
|
||||
);
|
||||
});
|
||||
|
||||
it('propagates client errors without swallowing them', async () => {
|
||||
client = makeMockClient({
|
||||
createTask: vi.fn().mockRejectedValue(new Error('DynamoDB write failed')),
|
||||
});
|
||||
tools = buildTaskTools(client);
|
||||
const call = getHandler(tools, 'create_task');
|
||||
|
||||
await expect(call({ title: 'Failing task' }, MOCK_CTX)).rejects.toThrow('DynamoDB write failed');
|
||||
});
|
||||
|
||||
it('scope guard: rejects callers without ops:tasks before touching the client', async () => {
|
||||
const call = getHandler(tools, 'create_task');
|
||||
|
||||
await expect(call({ title: 'Sneaky task' }, CTX_NO_TASKS)).rejects.toThrow();
|
||||
expect(client.createTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('tool metadata: name, tier, requiredScope are correct', () => {
|
||||
const tool = tools.find((t) => t.name === 'create_task')!;
|
||||
expect(tool.tier).toBe('ops');
|
||||
expect(tool.requiredScope).toBe('ops:tasks');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// list_tasks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('list_tasks', () => {
|
||||
let client: TasksClient;
|
||||
let tools: ReturnType<typeof buildTaskTools>;
|
||||
|
||||
beforeEach(() => {
|
||||
client = makeMockClient();
|
||||
tools = buildTaskTools(client);
|
||||
});
|
||||
|
||||
it('happy path: returns tasks with expected fields', async () => {
|
||||
const call = getHandler(tools, 'list_tasks');
|
||||
const result = await call({}, MOCK_CTX);
|
||||
|
||||
expect(result.tasks).toHaveLength(1);
|
||||
expect(result.tasks[0]).toMatchObject({
|
||||
taskId: 'task-001',
|
||||
title: 'Review vendor invoices',
|
||||
completed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('empty result: returns an empty array without error', async () => {
|
||||
client = makeMockClient({ listTasks: vi.fn().mockResolvedValue([]) });
|
||||
tools = buildTaskTools(client);
|
||||
const call = getHandler(tools, 'list_tasks');
|
||||
const result = await call({}, MOCK_CTX);
|
||||
|
||||
expect(result.tasks).toEqual([]);
|
||||
});
|
||||
|
||||
it('passes includeCompleted: false by default', async () => {
|
||||
const call = getHandler(tools, 'list_tasks');
|
||||
await call({}, MOCK_CTX);
|
||||
|
||||
expect(client.listTasks).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ includeCompleted: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it('passes includeCompleted: true when requested', async () => {
|
||||
const call = getHandler(tools, 'list_tasks');
|
||||
await call({ includeCompleted: true }, MOCK_CTX);
|
||||
|
||||
expect(client.listTasks).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ includeCompleted: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it('always passes ctx.sub to the client for ABAC', async () => {
|
||||
const call = getHandler(tools, 'list_tasks');
|
||||
await call({}, MOCK_CTX);
|
||||
|
||||
expect(client.listTasks).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sub: MOCK_CTX.sub }),
|
||||
);
|
||||
});
|
||||
|
||||
it('propagates client errors', async () => {
|
||||
client = makeMockClient({
|
||||
listTasks: vi.fn().mockRejectedValue(new Error('DynamoDB query failed')),
|
||||
});
|
||||
tools = buildTaskTools(client);
|
||||
const call = getHandler(tools, 'list_tasks');
|
||||
|
||||
await expect(call({}, MOCK_CTX)).rejects.toThrow('DynamoDB query failed');
|
||||
});
|
||||
|
||||
it('simulates a throttle error (ProvisionedThroughputExceededException)', async () => {
|
||||
const throttleError = Object.assign(
|
||||
new Error('ProvisionedThroughputExceededException: rate exceeded'),
|
||||
{ name: 'ProvisionedThroughputExceededException', $retryable: { throttling: true } },
|
||||
);
|
||||
client = makeMockClient({ listTasks: vi.fn().mockRejectedValue(throttleError) });
|
||||
tools = buildTaskTools(client);
|
||||
const call = getHandler(tools, 'list_tasks');
|
||||
|
||||
// The tool handler propagates the error; retry logic belongs at the transport layer.
|
||||
const err = await call({}, MOCK_CTX).catch((e) => e);
|
||||
expect(err.name).toBe('ProvisionedThroughputExceededException');
|
||||
});
|
||||
|
||||
it('scope guard: rejects callers without ops:tasks', async () => {
|
||||
const call = getHandler(tools, 'list_tasks');
|
||||
|
||||
await expect(call({}, CTX_NO_TASKS)).rejects.toThrow();
|
||||
expect(client.listTasks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('tool metadata: name, tier, requiredScope are correct', () => {
|
||||
const tool = tools.find((t) => t.name === 'list_tasks')!;
|
||||
expect(tool.tier).toBe('ops');
|
||||
expect(tool.requiredScope).toBe('ops:tasks');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// complete_task
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('complete_task', () => {
|
||||
let client: TasksClient;
|
||||
let tools: ReturnType<typeof buildTaskTools>;
|
||||
|
||||
beforeEach(() => {
|
||||
client = makeMockClient();
|
||||
tools = buildTaskTools(client);
|
||||
});
|
||||
|
||||
it('happy path: returns the completed task shape', async () => {
|
||||
const call = getHandler(tools, 'complete_task');
|
||||
const result = await call({ taskId: 'task-001' }, MOCK_CTX);
|
||||
|
||||
expect(result.task).toMatchObject({
|
||||
taskId: 'task-001',
|
||||
completed: true,
|
||||
completedAt: '2026-06-11T10:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('passes ctx.sub for ownership enforcement (ABAC)', async () => {
|
||||
const call = getHandler(tools, 'complete_task');
|
||||
await call({ taskId: 'task-001' }, MOCK_CTX);
|
||||
|
||||
expect(client.completeTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sub: MOCK_CTX.sub, taskId: 'task-001' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('propagates a ConditionalCheckFailedException (task not owned by caller)', async () => {
|
||||
const ownershipError = Object.assign(
|
||||
new Error('ConditionalCheckFailedException: condition not met'),
|
||||
{ name: 'ConditionalCheckFailedException' },
|
||||
);
|
||||
client = makeMockClient({ completeTask: vi.fn().mockRejectedValue(ownershipError) });
|
||||
tools = buildTaskTools(client);
|
||||
const call = getHandler(tools, 'complete_task');
|
||||
|
||||
const err = await call({ taskId: 'task-999' }, MOCK_CTX).catch((e) => e);
|
||||
expect(err.name).toBe('ConditionalCheckFailedException');
|
||||
});
|
||||
|
||||
it('propagates a throttle error', async () => {
|
||||
const throttleError = Object.assign(
|
||||
new Error('ProvisionedThroughputExceededException'),
|
||||
{ name: 'ProvisionedThroughputExceededException', $retryable: { throttling: true } },
|
||||
);
|
||||
client = makeMockClient({ completeTask: vi.fn().mockRejectedValue(throttleError) });
|
||||
tools = buildTaskTools(client);
|
||||
const call = getHandler(tools, 'complete_task');
|
||||
|
||||
const err = await call({ taskId: 'task-001' }, MOCK_CTX).catch((e) => e);
|
||||
expect(err.name).toBe('ProvisionedThroughputExceededException');
|
||||
});
|
||||
|
||||
it('scope guard: rejects callers without ops:tasks', async () => {
|
||||
const call = getHandler(tools, 'complete_task');
|
||||
|
||||
await expect(call({ taskId: 'task-001' }, CTX_NO_TASKS)).rejects.toThrow();
|
||||
expect(client.completeTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('tool metadata: tier and requiredScope', () => {
|
||||
const tool = tools.find((t) => t.name === 'complete_task')!;
|
||||
expect(tool.tier).toBe('ops');
|
||||
expect(tool.requiredScope).toBe('ops:tasks');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// delete_task
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('delete_task', () => {
|
||||
let client: TasksClient;
|
||||
let tools: ReturnType<typeof buildTaskTools>;
|
||||
|
||||
beforeEach(() => {
|
||||
client = makeMockClient();
|
||||
tools = buildTaskTools(client);
|
||||
});
|
||||
|
||||
it('happy path: returns deleted: true and the taskId', async () => {
|
||||
const call = getHandler(tools, 'delete_task');
|
||||
const result = await call({ taskId: 'task-001' }, MOCK_CTX);
|
||||
|
||||
expect(result).toEqual({ deleted: true, taskId: 'task-001' });
|
||||
});
|
||||
|
||||
it('passes ctx.sub for ownership enforcement (ABAC)', async () => {
|
||||
const call = getHandler(tools, 'delete_task');
|
||||
await call({ taskId: 'task-001' }, MOCK_CTX);
|
||||
|
||||
expect(client.deleteTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sub: MOCK_CTX.sub, taskId: 'task-001' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('propagates client errors', async () => {
|
||||
client = makeMockClient({
|
||||
deleteTask: vi.fn().mockRejectedValue(new Error('DynamoDB delete failed')),
|
||||
});
|
||||
tools = buildTaskTools(client);
|
||||
const call = getHandler(tools, 'delete_task');
|
||||
|
||||
await expect(call({ taskId: 'task-001' }, MOCK_CTX)).rejects.toThrow('DynamoDB delete failed');
|
||||
});
|
||||
|
||||
it('propagates a throttle error', async () => {
|
||||
const throttleError = Object.assign(
|
||||
new Error('ProvisionedThroughputExceededException'),
|
||||
{ name: 'ProvisionedThroughputExceededException', $retryable: { throttling: true } },
|
||||
);
|
||||
client = makeMockClient({ deleteTask: vi.fn().mockRejectedValue(throttleError) });
|
||||
tools = buildTaskTools(client);
|
||||
const call = getHandler(tools, 'delete_task');
|
||||
|
||||
const err = await call({ taskId: 'task-001' }, MOCK_CTX).catch((e) => e);
|
||||
expect(err.name).toBe('ProvisionedThroughputExceededException');
|
||||
});
|
||||
|
||||
it('scope guard: rejects callers without ops:tasks', async () => {
|
||||
const call = getHandler(tools, 'delete_task');
|
||||
|
||||
await expect(call({ taskId: 'task-001' }, CTX_NO_TASKS)).rejects.toThrow();
|
||||
expect(client.deleteTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('tool metadata: tier and requiredScope', () => {
|
||||
const tool = tools.find((t) => t.name === 'delete_task')!;
|
||||
expect(tool.tier).toBe('ops');
|
||||
expect(tool.requiredScope).toBe('ops:tasks');
|
||||
});
|
||||
});
|
||||
9
packages/tasks/tsconfig.json
Normal file
9
packages/tasks/tsconfig.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declarationDir": "./dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
33
tsconfig.base.json
Normal file
33
tsconfig.base.json
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"strictFunctionTypes": true,
|
||||
"strictBindCallApply": true,
|
||||
"strictPropertyInitialization": true,
|
||||
"noImplicitThis": true,
|
||||
"useUnknownInCatchVariables": true,
|
||||
"alwaysStrict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"composite": true,
|
||||
"incremental": true
|
||||
},
|
||||
"exclude": ["node_modules", "dist", "cdk.out", "coverage"]
|
||||
}
|
||||
39
tsconfig.json
Normal file
39
tsconfig.json
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
{
|
||||
"extends": "./tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true
|
||||
},
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./packages/calendar"
|
||||
},
|
||||
{
|
||||
"path": "./packages/gmail"
|
||||
},
|
||||
{
|
||||
"path": "./packages/google-maps"
|
||||
},
|
||||
{
|
||||
"path": "./packages/internal-data"
|
||||
},
|
||||
{
|
||||
"path": "./packages/knowledge-base"
|
||||
},
|
||||
{
|
||||
"path": "./packages/payments"
|
||||
},
|
||||
{
|
||||
"path": "./packages/qbo"
|
||||
},
|
||||
{
|
||||
"path": "./packages/reminders"
|
||||
},
|
||||
{
|
||||
"path": "./packages/shared"
|
||||
},
|
||||
{
|
||||
"path": "./packages/tasks"
|
||||
}
|
||||
]
|
||||
}
|
||||
30
vitest.config.ts
Normal file
30
vitest.config.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
include: ['packages/**/*.ts', 'servers/**/*.ts', 'jobs/**/*.ts', 'auth/**/*.ts'],
|
||||
exclude: [
|
||||
'node_modules/',
|
||||
'dist/',
|
||||
'**/*.d.ts',
|
||||
'**/*.test.ts',
|
||||
'**/*.spec.ts',
|
||||
],
|
||||
lines: 80,
|
||||
functions: 80,
|
||||
branches: 80,
|
||||
statements: 80,
|
||||
thresholds: {
|
||||
lines: 80,
|
||||
functions: 80,
|
||||
branches: 80,
|
||||
statements: 80,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue