sh-mcp/packages/qbo/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

143 lines
4.9 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.

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