/** * 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({ 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 { 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({ 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 { 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({ 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 { requireScope(ctx, 'finance:read'); const payments = await client.getByCheck(input.checkNumber); const redacted = payments.map(redactPayment); return { payments: redacted, count: redacted.length }; }, }); }