sh-mcp/packages/payments/src/tools.ts
Adam Moussa 0d1fefb326
Some checks are pending
deploy / deploy (push) Waiting to run
Phase 0b slice: monorepo scaffold + @sh-mcp/shared core + integration packages (#2)
* 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.
2026-06-26 12:42:17 -04:00

208 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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