sh-mcp/packages/payments/src/tools.ts
Adam Moussa 2e16c306b0 Add in-memory dev clients; make package tool exports lazy
Add an in-memory Client implementation per integration package (seeded fake
data, no network) selected when SH_MCP_ENV=local (build-plan §4). Gmail/calendar/
tasks dev clients partition by ctx.sub; payments/qbo seed sensitive-looking
fields so the redaction egress path has real targets to mask.

Make the eager default-tool exports in tasks/reminders/qbo LAZY (getDefaultTools)
so importing a package barrel no longer constructs an AWS client at module load
(build-plan §7 'no I/O at import time') — the previous eager construction broke
server startup. Fix payments tsconfig rootDir (src, was '.') so its declarations
resolve under dist/index.d.ts like the other 8 packages.
2026-06-26 12:48:26 -04:00

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