Phase 0b slice: monorepo scaffold + @sh-mcp/shared core + integration packages (#2)
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:
Adam Moussa 2026-06-26 12:42:17 -04:00 • committed by GitHub
parent 3e2d99a1eb
commit 0d1fefb326
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
82 changed files with 15910 additions and 13 deletions

17
.editorconfig Normal file
View 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
View 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
View 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
View file

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

File diff suppressed because it is too large Load diff

34
package.json Normal file
View 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
View file

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

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

View 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';

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

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

View file

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"declarationDir": "dist"
},
"include": ["src"]
}

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

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

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

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

View file

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"declarationDir": "dist"
},
"include": ["src"]
}

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

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

View 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>,
];
}

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

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

View file

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"declarationDir": "dist"
},
"include": ["src"]
}

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

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

View 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';

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

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

View file

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"declarationDir": "dist"
},
"include": ["src"]
}

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

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

View 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';

View 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];
}

View 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>

View 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>

View 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>

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

View file

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"declarationDir": "dist"
},
"include": ["src"]
}

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

View 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.",
);
}
}

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

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

View 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();
});
});

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

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

View file

@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"declarationDir": "./dist"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test"]
}

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

View 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.'
);
}
}

View 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'] ?? '',
}),
});

View 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];
}

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

View file

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"declarationDir": "dist"
},
"include": ["src"]
}

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

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

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

View 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';

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

View 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.',
},
},
},
};
}

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

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

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

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

View file

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"declarationDir": "dist"
},
"include": ["src"]
}

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

View 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)}`,
);
}
}

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

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

View file

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"declarationDir": "./dist"
},
"include": ["src"]
}

33
tsconfig.base.json Normal file
View 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
View 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
View 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,
},
},
},
});