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