From 2e16c306b09b5e6adb6e6635f46d0057de8978d2 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Wed, 24 Jun 2026 20:42:20 -0400 Subject: [PATCH] Add in-memory dev clients; make package tool exports lazy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/calendar/src/client.ts | 23 ++- packages/calendar/src/dev-client.ts | 164 ++++++++++++++++++++++ packages/calendar/src/index.ts | 1 + packages/calendar/src/tools.ts | 14 +- packages/gmail/src/dev-client.ts | 149 ++++++++++++++++++++ packages/gmail/src/index.ts | 1 + packages/gmail/src/tools.ts | 2 +- packages/google-maps/src/dev-client.ts | 57 ++++++++ packages/google-maps/src/index.ts | 15 +- packages/internal-data/src/client.ts | 38 +++-- packages/internal-data/src/dev-client.ts | 124 ++++++++++++++++ packages/internal-data/src/index.ts | 8 +- packages/internal-data/src/tools.ts | 5 +- packages/knowledge-base/src/client.ts | 13 +- packages/knowledge-base/src/dev-client.ts | 40 ++++++ packages/knowledge-base/src/index.ts | 6 +- packages/knowledge-base/src/tools.ts | 15 +- packages/payments/src/client.ts | 46 ++---- packages/payments/src/dev-client.ts | 84 +++++++++++ packages/payments/src/index.ts | 21 +-- packages/payments/src/tools.ts | 88 +++++------- packages/payments/tsconfig.json | 2 +- packages/qbo/src/dev-client.ts | 63 +++++++++ packages/qbo/src/index.ts | 28 +++- packages/qbo/src/tools.ts | 12 +- packages/reminders/src/client.ts | 8 +- packages/reminders/src/dev-client.ts | 25 ++++ packages/reminders/src/index.ts | 29 ++-- packages/reminders/src/tools.ts | 11 +- packages/shared/src/redact.ts | 25 ++-- packages/tasks/src/dev-client.ts | 111 +++++++++++++++ packages/tasks/src/index.ts | 39 +++-- packages/tasks/src/tools.ts | 21 ++- 33 files changed, 1051 insertions(+), 237 deletions(-) create mode 100644 packages/calendar/src/dev-client.ts create mode 100644 packages/gmail/src/dev-client.ts create mode 100644 packages/google-maps/src/dev-client.ts create mode 100644 packages/internal-data/src/dev-client.ts create mode 100644 packages/knowledge-base/src/dev-client.ts create mode 100644 packages/payments/src/dev-client.ts create mode 100644 packages/qbo/src/dev-client.ts create mode 100644 packages/reminders/src/dev-client.ts create mode 100644 packages/tasks/src/dev-client.ts diff --git a/packages/calendar/src/client.ts b/packages/calendar/src/client.ts index e4f0673..f1e5387 100644 --- a/packages/calendar/src/client.ts +++ b/packages/calendar/src/client.ts @@ -17,12 +17,12 @@ export interface CalendarEvent { id: string; summary: string; description?: string; - start: string; // ISO-8601 datetime or date - end: string; // ISO-8601 datetime or date + start: string; // ISO-8601 datetime or date + end: string; // ISO-8601 datetime or date attendees?: CalendarAttendee[]; /** True when at least one attendee is not @seahavenind.com */ hasExternalAttendees?: boolean; - status?: string; // confirmed | tentative | cancelled + status?: string; // confirmed | tentative | cancelled htmlLink?: string; } @@ -35,9 +35,9 @@ export interface CalendarAttendee { } export interface GetEventsOptions { - calendarId?: string; // defaults to 'primary' - timeMin: string; // ISO-8601 - timeMax: string; // ISO-8601 + calendarId?: string; // defaults to 'primary' + timeMin: string; // ISO-8601 + timeMax: string; // ISO-8601 maxResults?: number; singleEvents?: boolean; orderBy?: 'startTime' | 'updated'; @@ -58,11 +58,11 @@ export interface AvailabilityResult { } export interface CreateEventOptions { - calendarId?: string; // defaults to 'primary' + calendarId?: string; // defaults to 'primary' summary: string; description?: string; - start: string; // ISO-8601 datetime - end: string; // ISO-8601 datetime + start: string; // ISO-8601 datetime + end: string; // ISO-8601 datetime attendees?: Array<{ email: string; displayName?: string }>; location?: string; timeZone?: string; @@ -82,10 +82,7 @@ export interface CalendarClient { /** * Queries free/busy information for the user's calendar. */ - checkAvailability( - userSub: string, - opts: CheckAvailabilityOptions, - ): Promise; + checkAvailability(userSub: string, opts: CheckAvailabilityOptions): Promise; /** * Creates a calendar event and returns the created event. diff --git a/packages/calendar/src/dev-client.ts b/packages/calendar/src/dev-client.ts new file mode 100644 index 0000000..ca4c629 --- /dev/null +++ b/packages/calendar/src/dev-client.ts @@ -0,0 +1,164 @@ +/** + * InMemoryCalendarClient — deterministic in-memory CalendarClient for local dev. + * + * Implements the same CalendarClient interface as GoogleCalendarClient but holds + * a small set of FAKE seed events in memory, partitioned by userSub so a user + * only ever sees their own calendar. No network, no AWS, no token provider. + * + * This is for build-plan §4 "in-memory dev clients (tools return real data + * locally)" — wire it into a dev server instead of GoogleCalendarClient. + */ + +import type { + AvailabilityResult, + CalendarClient, + CalendarEvent, + CheckAvailabilityOptions, + CreateEventOptions, + GetEventsOptions, +} from './client.js'; + +/** Seed events keyed by userSub. Each user sees only their own list. */ +function seedEvents(): Map { + const map = new Map(); + + map.set('lauren@seahavenind.com', [ + { + id: 'evt-lauren-001', + summary: 'Vendor onboarding sync', + description: 'Walk through the new vendor intake checklist.', + start: '2026-06-25T15:00:00Z', + end: '2026-06-25T15:30:00Z', + attendees: [ + { + email: 'lauren@seahavenind.com', + responseStatus: 'accepted', + self: true, + organizer: true, + }, + { email: 'adam@seahavenind.com', responseStatus: 'accepted' }, + ], + hasExternalAttendees: false, + status: 'confirmed', + htmlLink: 'https://calendar.google.com/event?eid=evt-lauren-001', + }, + { + id: 'evt-lauren-002', + summary: 'Site walkthrough — Marina pier', + start: '2026-06-26T13:00:00Z', + end: '2026-06-26T14:00:00Z', + attendees: [ + { + email: 'lauren@seahavenind.com', + responseStatus: 'accepted', + self: true, + organizer: true, + }, + { email: 'contractor@example.com', responseStatus: 'needsAction' }, + ], + hasExternalAttendees: true, + status: 'confirmed', + }, + ]); + + map.set('adam@seahavenind.com', [ + { + id: 'evt-adam-001', + summary: 'Quarterly finance review', + description: 'Review payment dashboard and QBO vendor balances.', + start: '2026-06-24T17:00:00Z', + end: '2026-06-24T18:00:00Z', + attendees: [ + { email: 'adam@seahavenind.com', responseStatus: 'accepted', self: true, organizer: true }, + { email: 'lauren@seahavenind.com', responseStatus: 'tentative' }, + ], + hasExternalAttendees: false, + status: 'confirmed', + }, + ]); + + return map; +} + +/** + * In-memory CalendarClient. Events are partitioned by userSub; createEvent + * appends to the caller's own list and returns the created event. + */ +export class InMemoryCalendarClient implements CalendarClient { + private readonly eventsBySub: Map; + private counter = 1000; + + constructor() { + this.eventsBySub = seedEvents(); + } + + async getEvents(userSub: string, opts: GetEventsOptions): Promise { + const all = this.eventsBySub.get(userSub) ?? []; + const min = Date.parse(opts.timeMin); + const max = Date.parse(opts.timeMax); + const inWindow = all.filter((e) => { + const start = Date.parse(e.start); + return start >= min && start <= max; + }); + const limit = opts.maxResults ?? 50; + return inWindow.slice(0, limit); + } + + async checkAvailability( + userSub: string, + opts: CheckAvailabilityOptions, + ): Promise { + const min = Date.parse(opts.timeMin); + const max = Date.parse(opts.timeMax); + const all = this.eventsBySub.get(userSub) ?? []; + + const busy = all + .filter((e) => Date.parse(e.start) < max && Date.parse(e.end) > min) + .map((e) => ({ start: e.start, end: e.end })) + .sort((a, b) => Date.parse(a.start) - Date.parse(b.start)); + + const free: Array<{ start: string; end: string }> = []; + let cursor = min; + for (const slot of busy) { + const slotStart = Date.parse(slot.start); + if (slotStart > cursor) { + free.push({ + start: new Date(cursor).toISOString(), + end: new Date(slotStart).toISOString(), + }); + } + cursor = Math.max(cursor, Date.parse(slot.end)); + } + if (cursor < max) { + free.push({ start: new Date(cursor).toISOString(), end: new Date(max).toISOString() }); + } + + return { busy, free }; + } + + async createEvent(userSub: string, opts: CreateEventOptions): Promise { + const event: CalendarEvent = { + id: `evt-dev-${this.counter++}`, + summary: opts.summary, + start: opts.start, + end: opts.end, + status: 'confirmed', + htmlLink: `https://calendar.google.com/event?eid=evt-dev-${this.counter}`, + }; + if (opts.description !== undefined) { + event.description = opts.description; + } + if (opts.attendees !== undefined) { + event.attendees = opts.attendees.map((a) => + a.displayName !== undefined + ? { email: a.email, displayName: a.displayName, responseStatus: 'needsAction' } + : { email: a.email, responseStatus: 'needsAction' }, + ); + } + + const list = this.eventsBySub.get(userSub) ?? []; + list.push(event); + this.eventsBySub.set(userSub, list); + return event; + } +} diff --git a/packages/calendar/src/index.ts b/packages/calendar/src/index.ts index 4b7cba0..9fde1df 100644 --- a/packages/calendar/src/index.ts +++ b/packages/calendar/src/index.ts @@ -28,6 +28,7 @@ export type { CreateEventOptions, } from './client.js'; export { GoogleCalendarClient } from './client.js'; +export { InMemoryCalendarClient } from './dev-client.js'; export type { GetCalendarEventsInput, GetCalendarEventsOutput, diff --git a/packages/calendar/src/tools.ts b/packages/calendar/src/tools.ts index f091ef2..64ef1b5 100644 --- a/packages/calendar/src/tools.ts +++ b/packages/calendar/src/tools.ts @@ -16,11 +16,7 @@ import { defineTool, requireScope } from '@sh-mcp/shared'; import type { AuthContext } from '@sh-mcp/shared'; -import type { - CalendarClient, - CalendarEvent, - AvailabilityResult, -} from './client.js'; +import type { CalendarClient, CalendarEvent, AvailabilityResult } from './client.js'; import { CalendarClientError } from './client.js'; // --------------------------------------------------------------------------- @@ -131,7 +127,7 @@ export function buildCalendarTools(client: CalendarClient) { }, calendarId: { type: 'string', - description: 'Calendar ID to query. Defaults to \'primary\'.', + description: "Calendar ID to query. Defaults to 'primary'.", default: 'primary', }, maxResults: { @@ -181,7 +177,7 @@ export function buildCalendarTools(client: CalendarClient) { const checkAvailability = defineTool({ name: 'check_availability', description: - 'Check the authenticated user\'s free/busy availability within a time window. ' + + "Check the authenticated user's free/busy availability within a time window. " + 'Returns a list of busy blocks and derived free blocks. ' + 'Useful for scheduling and finding open meeting slots.', tier: 'ops', @@ -203,7 +199,7 @@ export function buildCalendarTools(client: CalendarClient) { }, calendarId: { type: 'string', - description: 'Calendar ID to check. Defaults to \'primary\'.', + description: "Calendar ID to check. Defaults to 'primary'.", default: 'primary', }, }, @@ -301,7 +297,7 @@ export function buildCalendarTools(client: CalendarClient) { }, calendarId: { type: 'string', - description: 'Calendar to create the event in. Defaults to \'primary\'.', + description: "Calendar to create the event in. Defaults to 'primary'.", default: 'primary', }, }, diff --git a/packages/gmail/src/dev-client.ts b/packages/gmail/src/dev-client.ts new file mode 100644 index 0000000..d47618e --- /dev/null +++ b/packages/gmail/src/dev-client.ts @@ -0,0 +1,149 @@ +/** + * InMemoryGmailClient — deterministic in-memory GmailClient for local dev. + * + * Holds a small set of FAKE inboxes/threads in memory, partitioned by userSub + * so a user only ever sees their own mail. searchInbox does a simple + * case-insensitive substring match against subject/snippet; getThreadDetail + * throws if the thread is not found or not owned by the caller. No network. + * + * This is for build-plan §4 "in-memory dev clients (tools return real data + * locally)". + */ + +import type { + EmailMessage, + EmailThread, + GetThreadDetailParams, + GmailClient, + SearchInboxParams, +} from './client.js'; + +interface SeedInbox { + messages: EmailMessage[]; + threads: Map; +} + +function seedInboxes(): Map { + const map = new Map(); + + map.set('lauren@seahavenind.com', { + messages: [ + { + id: 'msg-l-1', + threadId: 'thr-l-1', + subject: 'Invoice #4821 from Coastal Supply', + from: 'billing@coastalsupply.example', + to: 'lauren@seahavenind.com', + date: '2026-06-22T09:15:00Z', + snippet: 'Please find attached invoice #4821 for the dock hardware order.', + }, + { + id: 'msg-l-2', + threadId: 'thr-l-2', + subject: 'Re: Site walkthrough scheduling', + from: 'adam@seahavenind.com', + to: 'lauren@seahavenind.com', + date: '2026-06-23T14:02:00Z', + snippet: 'Friday afternoon works for the marina pier walkthrough.', + }, + ], + threads: new Map([ + [ + 'thr-l-1', + { + threadId: 'thr-l-1', + subject: 'Invoice #4821 from Coastal Supply', + messages: [ + { + id: 'msg-l-1', + from: 'billing@coastalsupply.example', + to: 'lauren@seahavenind.com', + date: '2026-06-22T09:15:00Z', + body: 'Please find attached invoice #4821 for the dock hardware order. Net 30 terms apply.', + }, + ], + }, + ], + [ + 'thr-l-2', + { + threadId: 'thr-l-2', + subject: 'Re: Site walkthrough scheduling', + messages: [ + { + id: 'msg-l-2', + from: 'adam@seahavenind.com', + to: 'lauren@seahavenind.com', + date: '2026-06-23T14:02:00Z', + body: 'Friday afternoon works for the marina pier walkthrough. I will bring the punch list.', + }, + ], + }, + ], + ]), + }); + + map.set('adam@seahavenind.com', { + messages: [ + { + id: 'msg-a-1', + threadId: 'thr-a-1', + subject: 'Payment confirmation — check #2087', + from: 'ap@seahavenind.com', + to: 'adam@seahavenind.com', + date: '2026-06-21T11:40:00Z', + snippet: 'Check #2087 to Harbor Electric has cleared.', + }, + ], + threads: new Map([ + [ + 'thr-a-1', + { + threadId: 'thr-a-1', + subject: 'Payment confirmation — check #2087', + messages: [ + { + id: 'msg-a-1', + from: 'ap@seahavenind.com', + to: 'adam@seahavenind.com', + date: '2026-06-21T11:40:00Z', + body: 'Check #2087 to Harbor Electric has cleared. The payment dashboard is updated.', + }, + ], + }, + ], + ]), + }); + + return map; +} + +/** In-memory GmailClient. Inboxes are partitioned by userSub. */ +export class InMemoryGmailClient implements GmailClient { + private readonly inboxes: Map; + + constructor() { + this.inboxes = seedInboxes(); + } + + async searchInbox(params: SearchInboxParams): Promise { + const inbox = this.inboxes.get(params.userSub); + if (!inbox) { + return []; + } + const q = params.query.toLowerCase(); + const matches = inbox.messages.filter( + (m) => m.subject.toLowerCase().includes(q) || m.snippet.toLowerCase().includes(q), + ); + return matches.slice(0, params.maxResults); + } + + async getThreadDetail(params: GetThreadDetailParams): Promise { + const inbox = this.inboxes.get(params.userSub); + const thread = inbox?.threads.get(params.threadId); + if (!thread) { + throw new Error(`Thread ${params.threadId} not found for user ${params.userSub}`); + } + return thread; + } +} diff --git a/packages/gmail/src/index.ts b/packages/gmail/src/index.ts index fd0597f..d5ab8ef 100644 --- a/packages/gmail/src/index.ts +++ b/packages/gmail/src/index.ts @@ -15,6 +15,7 @@ */ export { GmailApiClient } from './client.js'; +export { InMemoryGmailClient } from './dev-client.js'; export type { GmailClient, GoogleTokenProvider, diff --git a/packages/gmail/src/tools.ts b/packages/gmail/src/tools.ts index 0ef9a69..bfc18b9 100644 --- a/packages/gmail/src/tools.ts +++ b/packages/gmail/src/tools.ts @@ -54,7 +54,7 @@ export function makeSearchInboxTool(client: GmailClient) { return defineTool({ name: 'search_inbox', description: - 'Search the signed-in user\'s Gmail inbox using a Gmail query string. ' + + "Search the signed-in user's Gmail inbox using a Gmail query string. " + 'Returns matching message metadata and snippets. ' + 'Acts as the authenticated user — never reads another mailbox.', tier: 'ops', diff --git a/packages/google-maps/src/dev-client.ts b/packages/google-maps/src/dev-client.ts new file mode 100644 index 0000000..19db175 --- /dev/null +++ b/packages/google-maps/src/dev-client.ts @@ -0,0 +1,57 @@ +/** + * InMemoryGoogleMapsClient — deterministic in-memory GoogleMapsClient for dev. + * + * Returns a fixed set of FAKE vendor PlaceResults, lightly filtered by a + * case-insensitive substring match against textQuery and capped at + * maxResultCount. No network, no API key. + * + * This is for build-plan §4 "in-memory dev clients (tools return real data + * locally)". + */ + +import type { GoogleMapsClient, PlaceResult, SearchTextParams } from './client.js'; + +const SEED_PLACES: PlaceResult[] = [ + { + displayName: 'Harbor Electric Co.', + formattedAddress: '142 Dock St, Sea Haven, NY 11790', + nationalPhoneNumber: '(631) 555-0142', + types: ['electrician', 'point_of_interest', 'establishment'], + rating: 4.6, + googleMapsUri: 'https://maps.google.com/?cid=10000000000000000001', + }, + { + displayName: 'Coastal Supply & Hardware', + formattedAddress: '88 Marina Way, Sea Haven, NY 11790', + nationalPhoneNumber: '(631) 555-0188', + types: ['hardware_store', 'point_of_interest', 'establishment'], + rating: 4.2, + googleMapsUri: 'https://maps.google.com/?cid=10000000000000000002', + }, + { + displayName: 'Tidewater Plumbing LLC', + formattedAddress: '23 Bayfront Rd, Sea Haven, NY 11790', + nationalPhoneNumber: '(631) 555-0123', + types: ['plumber', 'point_of_interest', 'establishment'], + rating: 4.8, + googleMapsUri: 'https://maps.google.com/?cid=10000000000000000003', + }, +]; + +/** In-memory GoogleMapsClient returning seeded vendor places. */ +export class InMemoryGoogleMapsClient implements GoogleMapsClient { + async searchText(params: SearchTextParams): Promise { + const q = params.textQuery.trim().toLowerCase(); + const filtered = + q.length === 0 + ? SEED_PLACES + : SEED_PLACES.filter( + (p) => + p.displayName.toLowerCase().includes(q) || + p.types.some((t) => t.toLowerCase().includes(q)), + ); + const results = filtered.length > 0 ? filtered : SEED_PLACES; + const limit = params.maxResultCount ?? 10; + return results.slice(0, limit); + } +} diff --git a/packages/google-maps/src/index.ts b/packages/google-maps/src/index.ts index b61a125..86af081 100644 --- a/packages/google-maps/src/index.ts +++ b/packages/google-maps/src/index.ts @@ -19,8 +19,13 @@ */ export { GooglePlacesClient } from './client.js'; +export { InMemoryGoogleMapsClient } from './dev-client.js'; export type { GoogleMapsClient, PlaceResult, SearchTextParams } from './client.js'; -export type { SearchNearbyVendorsInput, SearchNearbyVendorsOutput, VendorListing } from './tools.js'; +export type { + SearchNearbyVendorsInput, + SearchNearbyVendorsOutput, + VendorListing, +} from './tools.js'; export { makeSearchNearbyVendors } from './tools.js'; import type { ToolDef } from '@sh-mcp/shared'; @@ -31,10 +36,6 @@ import { makeSearchNearbyVendors } from './tools.js'; * Build the full tools array wired to an injected client. * Servers call this at startup with their concrete GooglePlacesClient. */ -export function makeTools( - client: GoogleMapsClient, -): ToolDef[] { - return [ - makeSearchNearbyVendors(client) as ToolDef, - ]; +export function makeTools(client: GoogleMapsClient): ToolDef[] { + return [makeSearchNearbyVendors(client) as ToolDef]; } diff --git a/packages/internal-data/src/client.ts b/packages/internal-data/src/client.ts index e1f5ad3..d43b0ab 100644 --- a/packages/internal-data/src/client.ts +++ b/packages/internal-data/src/client.ts @@ -64,15 +64,15 @@ export interface InternalDataClient { // --------------------------------------------------------------------------- // Real (AWS SDK-backed) implementation // - // The AWS SDK import lives here — behind this class — so that: - // a) Nothing happens at module load time (no credential resolution, no env reads). - // b) Tests never reach this code; they inject a mock that satisfies the interface. - // - // TODO (DEFERRED auth layer): When the Gateway layer is built, the Lambda execution - // role will supply credentials via the standard AWS environment variables. At that - // point ensure the DocumentClient is constructed with the correct region and that - // the table names are injected via environment variables (WORK_ORDERS_TABLE, - // PURCHASE_ORDERS_TABLE, SITE_ASSIGNMENTS_TABLE) rather than hard-coded. +// The AWS SDK import lives here — behind this class — so that: +// a) Nothing happens at module load time (no credential resolution, no env reads). +// b) Tests never reach this code; they inject a mock that satisfies the interface. +// +// TODO (DEFERRED auth layer): When the Gateway layer is built, the Lambda execution +// role will supply credentials via the standard AWS environment variables. At that +// point ensure the DocumentClient is constructed with the correct region and that +// the table names are injected via environment variables (WORK_ORDERS_TABLE, +// PURCHASE_ORDERS_TABLE, SITE_ASSIGNMENTS_TABLE) rather than hard-coded. // --------------------------------------------------------------------------- export class RealDynamoClient implements InternalDataClient { @@ -114,9 +114,13 @@ export class RealDynamoClient implements InternalDataClient { const dynImport = (s: string): Promise => import(/* @vite-ignore */ s); // eslint-disable-next-line @typescript-eslint/no-explicit-any - const { DynamoDBClient } = (await dynImport('@aws-sdk/client-dynamodb')) as { DynamoDBClient: new (cfg: { region: string }) => any }; + const { DynamoDBClient } = (await dynImport('@aws-sdk/client-dynamodb')) as { + DynamoDBClient: new (cfg: { region: string }) => any; + }; // eslint-disable-next-line @typescript-eslint/no-explicit-any - const { DynamoDBDocumentClient } = (await dynImport('@aws-sdk/lib-dynamodb')) as { DynamoDBDocumentClient: { from: (c: any) => any } }; + const { DynamoDBDocumentClient } = (await dynImport('@aws-sdk/lib-dynamodb')) as { + DynamoDBDocumentClient: { from: (c: any) => any }; + }; const region = process.env['AWS_REGION'] ?? 'us-east-1'; // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment @@ -136,7 +140,9 @@ export class RealDynamoClient implements InternalDataClient { // eslint-disable-next-line @typescript-eslint/no-explicit-any const dynImport = (s: string): Promise => import(/* @vite-ignore */ s); // eslint-disable-next-line @typescript-eslint/no-explicit-any - const { GetCommand } = (await dynImport('@aws-sdk/lib-dynamodb')) as { GetCommand: new (i: any) => any }; + const { GetCommand } = (await dynImport('@aws-sdk/lib-dynamodb')) as { + GetCommand: new (i: any) => any; + }; // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment const result = await this.ddb.send( // eslint-disable-next-line @typescript-eslint/no-unsafe-call @@ -150,7 +156,9 @@ export class RealDynamoClient implements InternalDataClient { // eslint-disable-next-line @typescript-eslint/no-explicit-any const dynImport = (s: string): Promise => import(/* @vite-ignore */ s); // eslint-disable-next-line @typescript-eslint/no-explicit-any - const { GetCommand } = (await dynImport('@aws-sdk/lib-dynamodb')) as { GetCommand: new (i: any) => any }; + const { GetCommand } = (await dynImport('@aws-sdk/lib-dynamodb')) as { + GetCommand: new (i: any) => any; + }; // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment const result = await this.ddb.send( // eslint-disable-next-line @typescript-eslint/no-unsafe-call @@ -164,7 +172,9 @@ export class RealDynamoClient implements InternalDataClient { // eslint-disable-next-line @typescript-eslint/no-explicit-any const dynImport = (s: string): Promise => import(/* @vite-ignore */ s); // eslint-disable-next-line @typescript-eslint/no-explicit-any - const { GetCommand } = (await dynImport('@aws-sdk/lib-dynamodb')) as { GetCommand: new (i: any) => any }; + const { GetCommand } = (await dynImport('@aws-sdk/lib-dynamodb')) as { + GetCommand: new (i: any) => any; + }; // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment const result = await this.ddb.send( // eslint-disable-next-line @typescript-eslint/no-unsafe-call diff --git a/packages/internal-data/src/dev-client.ts b/packages/internal-data/src/dev-client.ts new file mode 100644 index 0000000..0915d97 --- /dev/null +++ b/packages/internal-data/src/dev-client.ts @@ -0,0 +1,124 @@ +/** + * InMemoryInternalDataClient — deterministic in-memory InternalDataClient. + * + * Holds a few FAKE work orders, purchase orders, and sites in memory. Returns + * null for unknown ids, matching the real client's contract. No AWS, no + * network. + * + * This is for build-plan §4 "in-memory dev clients (tools return real data + * locally)". + */ + +import type { + InternalDataClient, + PurchaseOrderRecord, + SiteRecord, + WorkOrderRecord, +} from './client.js'; + +function seedWorkOrders(): Map { + return new Map([ + [ + 'WO-1001', + { + workOrderId: 'WO-1001', + title: 'Replace dock lighting circuit', + status: 'in_progress', + siteId: 'SITE-01', + assignedTo: 'lauren@seahavenind.com', + createdAt: '2026-06-18T08:00:00Z', + updatedAt: '2026-06-22T16:30:00Z', + description: 'Pier B lighting circuit tripping under load; replace breaker and run.', + }, + ], + [ + 'WO-1002', + { + workOrderId: 'WO-1002', + title: 'Quarterly HVAC inspection', + status: 'open', + siteId: 'SITE-02', + createdAt: '2026-06-20T10:15:00Z', + updatedAt: '2026-06-20T10:15:00Z', + }, + ], + ]); +} + +function seedPurchaseOrders(): Map { + return new Map([ + [ + 'PO-2001', + { + purchaseOrderId: 'PO-2001', + vendor: 'Coastal Supply & Hardware', + status: 'approved', + totalAmount: 1842.5, + currency: 'USD', + issuedAt: '2026-06-15T12:00:00Z', + updatedAt: '2026-06-16T09:00:00Z', + lineItems: [ + { description: 'Marine-grade breaker 30A', quantity: 4, unitPrice: 210.0 }, + { description: 'THWN wire, 250ft spool', quantity: 1, unitPrice: 1002.5 }, + ], + }, + ], + [ + 'PO-2002', + { + purchaseOrderId: 'PO-2002', + vendor: 'Tidewater Plumbing LLC', + status: 'pending', + totalAmount: 640.0, + currency: 'USD', + issuedAt: '2026-06-19T15:30:00Z', + updatedAt: '2026-06-19T15:30:00Z', + }, + ], + ]); +} + +function seedSites(): Map { + return new Map([ + [ + 'SITE-01', + { + siteId: 'SITE-01', + name: 'Marina Pier Complex', + address: '88 Marina Way, Sea Haven, NY 11790', + region: 'North Shore', + status: 'active', + assignedTechnicians: ['lauren@seahavenind.com'], + }, + ], + [ + 'SITE-02', + { + siteId: 'SITE-02', + name: 'Bayfront Warehouse', + address: '23 Bayfront Rd, Sea Haven, NY 11790', + region: 'North Shore', + status: 'active', + }, + ], + ]); +} + +/** In-memory InternalDataClient seeded with a few fake records. */ +export class InMemoryInternalDataClient implements InternalDataClient { + private readonly workOrders = seedWorkOrders(); + private readonly purchaseOrders = seedPurchaseOrders(); + private readonly sites = seedSites(); + + async getWorkOrder(workOrderId: string): Promise { + return this.workOrders.get(workOrderId) ?? null; + } + + async getPurchaseOrder(purchaseOrderId: string): Promise { + return this.purchaseOrders.get(purchaseOrderId) ?? null; + } + + async getSite(siteId: string): Promise { + return this.sites.get(siteId) ?? null; + } +} diff --git a/packages/internal-data/src/index.ts b/packages/internal-data/src/index.ts index 58b754b..55e5238 100644 --- a/packages/internal-data/src/index.ts +++ b/packages/internal-data/src/index.ts @@ -15,5 +15,11 @@ export type { LookupSiteInput, LookupSiteOutput, } from './tools.js'; -export type { InternalDataClient, WorkOrderRecord, PurchaseOrderRecord, SiteRecord } from './client.js'; +export type { + InternalDataClient, + WorkOrderRecord, + PurchaseOrderRecord, + SiteRecord, +} from './client.js'; export { RealDynamoClient } from './client.js'; +export { InMemoryInternalDataClient } from './dev-client.js'; diff --git a/packages/internal-data/src/tools.ts b/packages/internal-data/src/tools.ts index 77430f2..6416f18 100644 --- a/packages/internal-data/src/tools.ts +++ b/packages/internal-data/src/tools.ts @@ -198,10 +198,7 @@ export function makeTools(client: InternalDataClient) { }, }, }, - handler: async ( - input: LookupSiteInput, - ctx: AuthContext, - ): Promise => { + handler: async (input: LookupSiteInput, ctx: AuthContext): Promise => { requireScope(ctx, 'ops:read'); const record = await client.getSite(input.siteId); diff --git a/packages/knowledge-base/src/client.ts b/packages/knowledge-base/src/client.ts index 794b4b7..102fe25 100644 --- a/packages/knowledge-base/src/client.ts +++ b/packages/knowledge-base/src/client.ts @@ -85,7 +85,8 @@ export class BedrockKnowledgeBaseClient implements KnowledgeBaseClient { // until the Lambda runtime bundle is assembled. The module specifier is stored in a // variable so TypeScript skips static module resolution at build time. // eslint-disable-next-line @typescript-eslint/no-explicit-any - const dynImport = (s: string): Promise => new Function('s', 'return import(s)')(s) as Promise; + const dynImport = (s: string): Promise => + new Function('s', 'return import(s)')(s) as Promise; interface BedrockRetrievalResult { location?: { s3Location?: { uri?: string }; type?: string }; @@ -97,9 +98,13 @@ export class BedrockKnowledgeBaseClient implements KnowledgeBaseClient { } // eslint-disable-next-line @typescript-eslint/no-explicit-any - const { BedrockAgentRuntimeClient, RetrieveCommand } = (await dynImport('@aws-sdk/client-bedrock-agent-runtime')) as { + const { BedrockAgentRuntimeClient, RetrieveCommand } = (await dynImport( + '@aws-sdk/client-bedrock-agent-runtime', + )) as { // eslint-disable-next-line @typescript-eslint/no-explicit-any - BedrockAgentRuntimeClient: new (cfg: { region: string }) => { send: (cmd: any) => Promise }; + BedrockAgentRuntimeClient: new (cfg: { region: string }) => { + send: (cmd: any) => Promise; + }; // eslint-disable-next-line @typescript-eslint/no-explicit-any RetrieveCommand: new (input: any) => unknown; }; @@ -140,7 +145,7 @@ export function createBedrockClientFromEnv(): BedrockKnowledgeBaseClient { const knowledgeBaseId = process.env['KNOWLEDGE_BASE_ID']; if (!knowledgeBaseId) { throw new Error( - 'KNOWLEDGE_BASE_ID environment variable is required for the production KB client' + 'KNOWLEDGE_BASE_ID environment variable is required for the production KB client', ); } return new BedrockKnowledgeBaseClient({ diff --git a/packages/knowledge-base/src/dev-client.ts b/packages/knowledge-base/src/dev-client.ts new file mode 100644 index 0000000..9179abc --- /dev/null +++ b/packages/knowledge-base/src/dev-client.ts @@ -0,0 +1,40 @@ +/** + * InMemoryKnowledgeBaseClient — deterministic in-memory KnowledgeBaseClient. + * + * Returns a fixed set of FAKE retrieval passages, respecting maxResults + * (default 5). No AWS, no Bedrock, no network. + * + * This is for build-plan §4 "in-memory dev clients (tools return real data + * locally)". + */ + +import type { KnowledgeBaseClient, KnowledgeBaseResult, RetrieveOptions } from './client.js'; + +const SEED_RESULTS: KnowledgeBaseResult[] = [ + { + source: 's3://sh-mcp-kb/handbook/vendor-onboarding.md', + score: 0.92, + passage: + 'New vendors must complete the W-9 intake form and provide a certificate of insurance before any purchase order is issued.', + }, + { + source: 's3://sh-mcp-kb/handbook/payment-terms.md', + score: 0.84, + passage: + 'Standard payment terms are Net 30. Early-payment discounts of 2% are available when invoices are paid within 10 days.', + }, + { + source: 's3://sh-mcp-kb/runbooks/dock-electrical.md', + score: 0.77, + passage: + 'Dock lighting circuits are rated for 30A. A repeated trip under load usually indicates a degraded breaker or a wet junction box.', + }, +]; + +/** In-memory KnowledgeBaseClient returning seeded passages. */ +export class InMemoryKnowledgeBaseClient implements KnowledgeBaseClient { + async retrieve(options: RetrieveOptions): Promise { + const limit = options.maxResults ?? 5; + return SEED_RESULTS.slice(0, limit); + } +} diff --git a/packages/knowledge-base/src/index.ts b/packages/knowledge-base/src/index.ts index 4c232cd..895e965 100644 --- a/packages/knowledge-base/src/index.ts +++ b/packages/knowledge-base/src/index.ts @@ -22,10 +22,8 @@ export type { SearchKnowledgeBaseResultItem, } from './tools.js'; -export { - BedrockKnowledgeBaseClient, - createBedrockClientFromEnv, -} from './client.js'; +export { BedrockKnowledgeBaseClient, createBedrockClientFromEnv } from './client.js'; +export { InMemoryKnowledgeBaseClient } from './dev-client.js'; export type { KnowledgeBaseClient, KnowledgeBaseResult, diff --git a/packages/knowledge-base/src/tools.ts b/packages/knowledge-base/src/tools.ts index f580857..a41ac82 100644 --- a/packages/knowledge-base/src/tools.ts +++ b/packages/knowledge-base/src/tools.ts @@ -54,12 +54,9 @@ export interface SearchKnowledgeBaseOutput { * const tools = createKnowledgeBaseTools(mockClient); */ export function createKnowledgeBaseTools( - client: KnowledgeBaseClient + client: KnowledgeBaseClient, ): ToolDef[] { - const searchKnowledgeBase = defineTool< - SearchKnowledgeBaseInput, - SearchKnowledgeBaseOutput - >({ + const searchKnowledgeBase = defineTool({ name: 'search_knowledge_base', description: 'Search the Sea Haven internal knowledge base for relevant information. ' + @@ -73,15 +70,13 @@ export function createKnowledgeBaseTools( properties: { query: { type: 'string', - description: - 'Natural-language question or keyword search query.', + description: 'Natural-language question or keyword search query.', minLength: 1, maxLength: 1000, }, maxResults: { type: 'integer', - description: - 'Maximum number of passages to return (1–20). Defaults to 5.', + description: 'Maximum number of passages to return (1–20). Defaults to 5.', minimum: 1, maximum: 20, default: 5, @@ -93,7 +88,7 @@ export function createKnowledgeBaseTools( async handler( input: SearchKnowledgeBaseInput, - ctx: AuthContext + ctx: AuthContext, ): Promise { // Server-side scope enforcement — never rely solely on UI tool-hiding. requireScope(ctx, 'ops:read'); diff --git a/packages/payments/src/client.ts b/packages/payments/src/client.ts index a8f66c5..bdf6134 100644 --- a/packages/payments/src/client.ts +++ b/packages/payments/src/client.ts @@ -22,7 +22,7 @@ export interface Payment { checkNumber?: string; /** ISO-8601 date string */ paymentDate: string; - status: "pending" | "cleared" | "voided" | "failed"; + status: 'pending' | 'cleared' | 'voided' | 'failed'; /** Bank account number — MUST be redacted before leaving the server */ bankAccountNumber?: string; /** Bank routing number — MUST be redacted before leaving the server */ @@ -43,22 +43,13 @@ export interface PaymentsQueryOptions { */ export interface PaymentsClient { /** Return all payments for a given vendor name (case-insensitive prefix match). */ - getByVendor( - vendor: string, - opts?: PaymentsQueryOptions, - ): Promise; + getByVendor(vendor: string, opts?: PaymentsQueryOptions): Promise; /** Return the payment(s) matching an invoice number. */ - getByInvoice( - invoiceNumber: string, - opts?: PaymentsQueryOptions, - ): Promise; + getByInvoice(invoiceNumber: string, opts?: PaymentsQueryOptions): Promise; /** Return the payment matching a check number. */ - getByCheck( - checkNumber: string, - opts?: PaymentsQueryOptions, - ): Promise; + getByCheck(checkNumber: string, opts?: PaymentsQueryOptions): Promise; } // --------------------------------------------------------------------------- @@ -82,7 +73,7 @@ export interface PaymentsClient { export class DynamoPaymentsClient implements PaymentsClient { private readonly tableName: string; - constructor(tableName = process.env["PAYMENTS_TABLE"] ?? "PaymentsDashboard") { + constructor(tableName = process.env['PAYMENTS_TABLE'] ?? 'PaymentsDashboard') { this.tableName = tableName; // The DynamoDB DocumentClient is intentionally NOT instantiated here to // avoid any AWS SDK import side-effects at module load time. Instantiate @@ -90,33 +81,18 @@ export class DynamoPaymentsClient implements PaymentsClient { void this.tableName; // suppress unused-var lint until real impl lands } - async getByVendor( - _vendor: string, - _opts?: PaymentsQueryOptions, - ): Promise { + async getByVendor(_vendor: string, _opts?: PaymentsQueryOptions): Promise { // TODO(real-impl): query VendorIndex GSI with vendor_normalized = vendor.toLowerCase() - throw new Error( - "DynamoPaymentsClient is a stub — inject a real or mock client instead.", - ); + throw new Error('DynamoPaymentsClient is a stub — inject a real or mock client instead.'); } - async getByInvoice( - _invoiceNumber: string, - _opts?: PaymentsQueryOptions, - ): Promise { + async getByInvoice(_invoiceNumber: string, _opts?: PaymentsQueryOptions): Promise { // TODO(real-impl): query InvoiceIndex GSI with invoiceNumber = invoiceNumber - throw new Error( - "DynamoPaymentsClient is a stub — inject a real or mock client instead.", - ); + throw new Error('DynamoPaymentsClient is a stub — inject a real or mock client instead.'); } - async getByCheck( - _checkNumber: string, - _opts?: PaymentsQueryOptions, - ): Promise { + async getByCheck(_checkNumber: string, _opts?: PaymentsQueryOptions): Promise { // TODO(real-impl): query CheckIndex GSI with checkNumber = checkNumber - throw new Error( - "DynamoPaymentsClient is a stub — inject a real or mock client instead.", - ); + throw new Error('DynamoPaymentsClient is a stub — inject a real or mock client instead.'); } } diff --git a/packages/payments/src/dev-client.ts b/packages/payments/src/dev-client.ts new file mode 100644 index 0000000..da45061 --- /dev/null +++ b/packages/payments/src/dev-client.ts @@ -0,0 +1,84 @@ +/** + * InMemoryPaymentsClient — deterministic in-memory PaymentsClient for dev. + * + * Holds a few FAKE payment records in memory. CRITICAL: the seed records carry + * realistic-looking sensitive values (bank account/routing numbers, card + * number) so the redaction egress path has something to mask before a response + * leaves the server. These are fabricated test values, not real account data. + * + * This is for build-plan §4 "in-memory dev clients (tools return real data + * locally)". + */ + +import type { Payment, PaymentsClient, PaymentsQueryOptions } from './client.js'; + +const SEED_PAYMENTS: Payment[] = [ + { + paymentId: 'PAY-9001', + vendor: 'Harbor Electric Co.', + vendorContact: 'ap@harborelectric.example', + amount: 4820.75, + currency: 'USD', + invoiceNumber: 'INV-4821', + checkNumber: '2087', + paymentDate: '2026-06-21T00:00:00Z', + status: 'cleared', + bankAccountNumber: '123456789012', + bankRoutingNumber: '021000021', + cardNumber: '4111111111111111', + memo: 'Dock lighting circuit replacement — WO-1001', + }, + { + paymentId: 'PAY-9002', + vendor: 'Coastal Supply & Hardware', + amount: 1842.5, + currency: 'USD', + invoiceNumber: 'INV-3310', + checkNumber: '2088', + paymentDate: '2026-06-22T00:00:00Z', + status: 'pending', + bankAccountNumber: '987654321098', + bankRoutingNumber: '011401533', + cardNumber: '5500000000000004', + memo: 'PO-2001 hardware order', + }, + { + paymentId: 'PAY-9003', + vendor: 'Tidewater Plumbing LLC', + amount: 640.0, + currency: 'USD', + invoiceNumber: 'INV-7755', + checkNumber: '2089', + paymentDate: '2026-06-23T00:00:00Z', + status: 'cleared', + bankAccountNumber: '456789012345', + bankRoutingNumber: '026009593', + cardNumber: '340000000000009', + memo: 'Bayfront warehouse fixture repair', + }, +]; + +/** In-memory PaymentsClient seeded with sensitive-bearing fake records. */ +export class InMemoryPaymentsClient implements PaymentsClient { + private readonly payments: Payment[] = SEED_PAYMENTS.map((p) => ({ ...p })); + + private applyLimit(rows: Payment[], opts?: PaymentsQueryOptions): Payment[] { + return opts?.limit !== undefined ? rows.slice(0, opts.limit) : rows; + } + + async getByVendor(vendor: string, opts?: PaymentsQueryOptions): Promise { + const needle = vendor.toLowerCase(); + const matches = this.payments.filter((p) => p.vendor.toLowerCase().includes(needle)); + return this.applyLimit(matches, opts); + } + + async getByInvoice(invoiceNumber: string, opts?: PaymentsQueryOptions): Promise { + const matches = this.payments.filter((p) => p.invoiceNumber === invoiceNumber); + return this.applyLimit(matches, opts); + } + + async getByCheck(checkNumber: string, opts?: PaymentsQueryOptions): Promise { + const matches = this.payments.filter((p) => p.checkNumber === checkNumber); + return this.applyLimit(matches, opts); + } +} diff --git a/packages/payments/src/index.ts b/packages/payments/src/index.ts index 54508bc..f7db8ea 100644 --- a/packages/payments/src/index.ts +++ b/packages/payments/src/index.ts @@ -13,8 +13,9 @@ * // register tools with the server registry */ -export type { PaymentsClient, Payment, PaymentsQueryOptions } from "./client.js"; -export { DynamoPaymentsClient } from "./client.js"; +export type { PaymentsClient, Payment, PaymentsQueryOptions } from './client.js'; +export { DynamoPaymentsClient } from './client.js'; +export { InMemoryPaymentsClient } from './dev-client.js'; export type { LookupByVendorInput, @@ -23,19 +24,11 @@ export type { LookupByInvoiceOutput, LookupByCheckInput, LookupByCheckOutput, -} from "./tools.js"; -export { - makeLookupByVendorTool, - makeLookupByInvoiceTool, - makeLookupByCheckTool, -} from "./tools.js"; +} from './tools.js'; +export { makeLookupByVendorTool, makeLookupByInvoiceTool, makeLookupByCheckTool } from './tools.js'; -import type { PaymentsClient } from "./client.js"; -import { - makeLookupByVendorTool, - makeLookupByInvoiceTool, - makeLookupByCheckTool, -} from "./tools.js"; +import type { PaymentsClient } from './client.js'; +import { makeLookupByVendorTool, makeLookupByInvoiceTool, makeLookupByCheckTool } from './tools.js'; /** * Build the full payments tool array for a given client. diff --git a/packages/payments/src/tools.ts b/packages/payments/src/tools.ts index a78b3f5..1334639 100644 --- a/packages/payments/src/tools.ts +++ b/packages/payments/src/tools.ts @@ -17,9 +17,9 @@ * 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"; +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 @@ -68,39 +68,35 @@ export interface LookupByVendorOutput { export function makeLookupByVendorTool(client: PaymentsClient) { return defineTool({ - name: "lookup_payment_by_vendor", + 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", + '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", + type: 'object', properties: { vendor: { - type: "string", - description: - "Vendor name to search for (case-insensitive prefix match).", + 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.", + type: 'integer', + description: 'Maximum number of results to return (1–100). Defaults to 20.', minimum: 1, maximum: 100, default: 20, }, }, - required: ["vendor"], + required: ['vendor'], additionalProperties: false, }, - async handler( - input: LookupByVendorInput, - ctx: AuthContext, - ): Promise { - requireScope(ctx, "finance:read"); + 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 }); @@ -126,30 +122,27 @@ export interface LookupByInvoiceOutput { export function makeLookupByInvoiceTool(client: PaymentsClient) { return defineTool({ - name: "lookup_payment_by_invoice", + 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", + '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", + type: 'object', properties: { invoiceNumber: { - type: "string", - description: "The invoice number to look up (exact match).", + type: 'string', + description: 'The invoice number to look up (exact match).', minLength: 1, maxLength: 100, }, }, - required: ["invoiceNumber"], + required: ['invoiceNumber'], additionalProperties: false, }, - async handler( - input: LookupByInvoiceInput, - ctx: AuthContext, - ): Promise { - requireScope(ctx, "finance:read"); + async handler(input: LookupByInvoiceInput, ctx: AuthContext): Promise { + requireScope(ctx, 'finance:read'); const payments = await client.getByInvoice(input.invoiceNumber); const redacted = payments.map(redactPayment); @@ -174,30 +167,27 @@ export interface LookupByCheckOutput { export function makeLookupByCheckTool(client: PaymentsClient) { return defineTool({ - name: "lookup_payment_by_check", + 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", + '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", + type: 'object', properties: { checkNumber: { - type: "string", - description: "The check number to look up (exact match).", + type: 'string', + description: 'The check number to look up (exact match).', minLength: 1, maxLength: 50, }, }, - required: ["checkNumber"], + required: ['checkNumber'], additionalProperties: false, }, - async handler( - input: LookupByCheckInput, - ctx: AuthContext, - ): Promise { - requireScope(ctx, "finance:read"); + async handler(input: LookupByCheckInput, ctx: AuthContext): Promise { + requireScope(ctx, 'finance:read'); const payments = await client.getByCheck(input.checkNumber); const redacted = payments.map(redactPayment); diff --git a/packages/payments/tsconfig.json b/packages/payments/tsconfig.json index 6a0c274..6181c2a 100644 --- a/packages/payments/tsconfig.json +++ b/packages/payments/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "rootDir": ".", + "rootDir": "src", "outDir": "./dist", "declarationDir": "./dist" }, diff --git a/packages/qbo/src/dev-client.ts b/packages/qbo/src/dev-client.ts new file mode 100644 index 0000000..91997f3 --- /dev/null +++ b/packages/qbo/src/dev-client.ts @@ -0,0 +1,63 @@ +/** + * InMemoryQboClient — deterministic in-memory QboClientInterface for dev. + * + * Holds a few FAKE QBO vendor records in memory. Each carries a fabricated + * taxId so the redaction egress path has a sensitive target to mask before a + * response leaves the server. Filters by case-insensitive substring on + * displayName and respects maxResults. No OAuth, no network. + * + * This is for build-plan §4 "in-memory dev clients (tools return real data + * locally)". + */ + +import type { + QboClientInterface, + QboVendor, + SearchVendorsParams, + SearchVendorsResult, +} from './client.js'; + +const SEED_VENDORS: QboVendor[] = [ + { + id: '101', + displayName: 'Harbor Electric Co.', + email: 'ap@harborelectric.example', + phone: '(631) 555-0142', + active: true, + balance: 0, + taxId: '12-3456789', + }, + { + id: '102', + displayName: 'Coastal Supply & Hardware', + email: 'billing@coastalsupply.example', + phone: '(631) 555-0188', + active: true, + balance: 1842.5, + taxId: '98-7654321', + }, + { + id: '103', + displayName: 'Tidewater Plumbing LLC', + phone: '(631) 555-0123', + active: false, + balance: 0, + taxId: '45-6789012', + }, +]; + +/** In-memory QboClientInterface seeded with fake vendors (taxId populated). */ +export class InMemoryQboClient implements QboClientInterface { + async searchVendors(params: SearchVendorsParams): Promise { + const needle = params.query.trim().toLowerCase(); + const matched = + needle.length === 0 + ? SEED_VENDORS + : SEED_VENDORS.filter((v) => v.displayName.toLowerCase().includes(needle)); + const limit = params.maxResults ?? 20; + return { + vendors: matched.slice(0, limit), + totalCount: matched.length, + }; + } +} diff --git a/packages/qbo/src/index.ts b/packages/qbo/src/index.ts index 276a76e..d09ab7f 100644 --- a/packages/qbo/src/index.ts +++ b/packages/qbo/src/index.ts @@ -11,18 +11,32 @@ export { makeSearchVendorsTool } from './tools.js'; export type { SearchVendorsInput, SearchVendorsOutput, VendorRecord } from './tools.js'; -export type { QboClientInterface, QboVendor, SearchVendorsParams, SearchVendorsResult } from './client.js'; +export type { + QboClientInterface, + QboVendor, + SearchVendorsParams, + SearchVendorsResult, +} from './client.js'; export { QboClientImpl, QboApiError, QboThrottleError } from './client.js'; +export { InMemoryQboClient } from './dev-client.js'; import { makeSearchVendorsTool } from './tools.js'; import { QboClientImpl } from './client.js'; +import type { ToolDef } from '@sh-mcp/shared'; + +let _defaultTools: ToolDef[] | undefined; /** - * Default tools array wired with QboClientImpl. + * Build (once) the default tools wired with QboClientImpl. * - * NOTE: QboClientImpl.searchVendors is stubbed (throws NotImplementedError) - * until the real OAuth/Secrets Manager integration is built (see client.ts - * TODO). For production use, instantiate QboClientImpl only after the - * DEFERRED auth layer is in place, or inject your own QboClientInterface. + * LAZY by design (build-plan §7): construction is deferred to first call so + * importing `@sh-mcp/qbo` is side-effect-free. `QboClientImpl.searchVendors` is + * stubbed (throws) until the real OAuth/Secrets integration lands; servers wire + * via `makeSearchVendorsTool` with their selected client (dev vs real). */ -export const tools = [makeSearchVendorsTool(new QboClientImpl())]; +export function getDefaultTools(): ToolDef[] { + if (!_defaultTools) { + _defaultTools = [makeSearchVendorsTool(new QboClientImpl()) as ToolDef]; + } + return _defaultTools; +} diff --git a/packages/qbo/src/tools.ts b/packages/qbo/src/tools.ts index c22e932..356f76e 100644 --- a/packages/qbo/src/tools.ts +++ b/packages/qbo/src/tools.ts @@ -49,8 +49,7 @@ const searchVendorsInputSchema = { query: { type: 'string', description: - 'Search term matched against vendor display name. ' + - 'Case-insensitive substring match.', + 'Search term matched against vendor display name. ' + 'Case-insensitive substring match.', minLength: 1, maxLength: 200, }, @@ -93,10 +92,7 @@ export function makeSearchVendorsTool(client: QboClientInterface) { requiredScope: 'finance:read', inputSchema: searchVendorsInputSchema, - async handler( - input: SearchVendorsInput, - ctx: AuthContext, - ): Promise { + async handler(input: SearchVendorsInput, ctx: AuthContext): Promise { // Server-side scope enforcement — authoritative, not a UI hint. requireScope(ctx, 'finance:read'); @@ -112,9 +108,7 @@ export function makeSearchVendorsTool(client: QboClientInterface) { if (err instanceof QboThrottleError) { // Surface throttle detail so callers can back off. const waitHint = - err.retryAfterSeconds !== undefined - ? ` Retry after ${err.retryAfterSeconds}s.` - : ''; + err.retryAfterSeconds !== undefined ? ` Retry after ${err.retryAfterSeconds}s.` : ''; throw new Error(`QBO rate limit exceeded.${waitHint}`); } if (err instanceof QboApiError) { diff --git a/packages/reminders/src/client.ts b/packages/reminders/src/client.ts index b37c6fc..50a549a 100644 --- a/packages/reminders/src/client.ts +++ b/packages/reminders/src/client.ts @@ -62,11 +62,7 @@ export class AwsSchedulerClient implements SchedulerClient { private readonly _targetArn: string; private readonly _roleArn: string; - constructor(opts: { - region?: string; - targetArn: string; - roleArn: string; - }) { + constructor(opts: { region?: string; targetArn: string; roleArn: string }) { this._region = opts.region ?? process.env['AWS_REGION'] ?? 'us-east-1'; this._targetArn = opts.targetArn; this._roleArn = opts.roleArn; @@ -104,7 +100,7 @@ export class AwsSchedulerClient implements SchedulerClient { throw new Error( 'AwsSchedulerClient.createSchedule: production AWS SDK call is not yet wired. ' + 'Inject a SchedulerClient mock in tests, or complete the IAM cross-review and ' + - 'uncomment the real SDK call before deploying.' + 'uncomment the real SDK call before deploying.', ); } } diff --git a/packages/reminders/src/dev-client.ts b/packages/reminders/src/dev-client.ts new file mode 100644 index 0000000..fba058c --- /dev/null +++ b/packages/reminders/src/dev-client.ts @@ -0,0 +1,25 @@ +/** + * InMemorySchedulerClient — deterministic in-memory SchedulerClient for dev. + * + * Records each createSchedule call in a public readonly `created` array (for + * testability) and returns a deterministic fake schedule ARN. No AWS, no + * network, no IAM. + * + * This is for build-plan §4 "in-memory dev clients (tools return real data + * locally)". + */ + +import type { CreateScheduleInput, CreateScheduleOutput, SchedulerClient } from './client.js'; + +/** In-memory SchedulerClient that fakes EventBridge schedule creation. */ +export class InMemorySchedulerClient implements SchedulerClient { + /** Every schedule input passed to createSchedule, in call order. */ + public readonly created: CreateScheduleInput[] = []; + + async createSchedule(input: CreateScheduleInput): Promise { + this.created.push(input); + return { + scheduleArn: `arn:aws:scheduler:us-east-1:000000000000:schedule/dev/${input.scheduleName}`, + }; + } +} diff --git a/packages/reminders/src/index.ts b/packages/reminders/src/index.ts index 08a8133..8a7ec22 100644 --- a/packages/reminders/src/index.ts +++ b/packages/reminders/src/index.ts @@ -16,18 +16,29 @@ export { buildReminderTools } from './tools.js'; export type { CreateReminderInput, CreateReminderOutput } from './tools.js'; export type { SchedulerClient, CreateScheduleInput, CreateScheduleOutput } from './client.js'; export { AwsSchedulerClient } from './client.js'; +export { InMemorySchedulerClient } from './dev-client.js'; import { buildReminderTools } from './tools.js'; import { AwsSchedulerClient } from './client.js'; +let _defaultTools: ReturnType | undefined; + /** - * Default tool array — uses the production AwsSchedulerClient. - * The client's constructor does NOT call AWS; the call only happens in the handler. - * Import at server startup only after CDK env vars are available. + * Build (once) the default tools wired to the production AwsSchedulerClient. + * + * LAZY by design (build-plan §7): construction is deferred to first call so + * importing `@sh-mcp/reminders` performs no work and reads no env at import + * time. Reads the scheduler env only when first invoked, by which point CDK + * env vars are present. Servers normally wire via `buildReminderTools` directly. */ -export const tools = buildReminderTools({ - client: new AwsSchedulerClient({ - targetArn: process.env['REMINDER_TARGET_ARN'] ?? '', - roleArn: process.env['SCHEDULER_ROLE_ARN'] ?? '', - }), -}); +export function getDefaultTools(): ReturnType { + if (!_defaultTools) { + _defaultTools = buildReminderTools({ + client: new AwsSchedulerClient({ + targetArn: process.env['REMINDER_TARGET_ARN'] ?? '', + roleArn: process.env['SCHEDULER_ROLE_ARN'] ?? '', + }), + }); + } + return _defaultTools; +} diff --git a/packages/reminders/src/tools.ts b/packages/reminders/src/tools.ts index 375c104..5cc0f24 100644 --- a/packages/reminders/src/tools.ts +++ b/packages/reminders/src/tools.ts @@ -78,7 +78,7 @@ export function buildReminderTools(deps: { client: SchedulerClient }) { handler: async ( input: CreateReminderInput, - ctx: AuthContext + ctx: AuthContext, ): Promise => { // Enforce the required scope server-side on every invocation. // requireScope throws ScopeError if the scope is missing. @@ -93,18 +93,15 @@ export function buildReminderTools(deps: { client: SchedulerClient }) { if (isNaN(fireAt.getTime())) { throw new Error( `create_reminder: invalid remind_at value "${input.remind_at}". ` + - 'Provide a valid ISO-8601 datetime string.' + 'Provide a valid ISO-8601 datetime string.', ); } if (fireAt.getTime() <= Date.now() + 60_000) { - throw new Error( - 'create_reminder: remind_at must be at least 1 minute in the future.' - ); + throw new Error('create_reminder: remind_at must be at least 1 minute in the future.'); } // Resolve the recipient: default to calling user sub. - const recipient = - !input.recipient || input.recipient === 'me' ? ctx.sub : input.recipient; + const recipient = !input.recipient || input.recipient === 'me' ? ctx.sub : input.recipient; // Derive a safe, unique schedule name from the user sub + timestamp. // EventBridge schedule names: [a-zA-Z0-9_-], max 64 chars. diff --git a/packages/shared/src/redact.ts b/packages/shared/src/redact.ts index 09abefe..a163366 100644 --- a/packages/shared/src/redact.ts +++ b/packages/shared/src/redact.ts @@ -42,10 +42,7 @@ export const REDACTED = '[REDACTED]'; */ function redactSSN(value: string): string { // Canonical and spaced formats (unambiguous) - let result = value.replace( - /\b(\d{3})[- ](\d{2})[- ](\d{4})\b/g, - REDACTED, - ); + let result = value.replace(/\b(\d{3})[- ](\d{2})[- ](\d{4})\b/g, REDACTED); // Bare 9-digit SSN preceded by an SSN keyword result = result.replace( /\b(ssn|social\s+security(?:\s+number)?|tax\s+id)\s*[:#]?\s*(\d{9})\b/gi, @@ -71,10 +68,7 @@ function redactRouting(value: string): string { (_match, keyword) => `${keyword.trim()} ${REDACTED}`, ); // Keyword AFTER the number: "021000021 (routing)" - result = result.replace( - /\b(\d{9})\s*\((routing|aba)\)/gi, - `${REDACTED} ($2)`, - ); + result = result.replace(/\b(\d{9})\s*\((routing|aba)\)/gi, `${REDACTED} ($2)`); return result; } @@ -116,15 +110,12 @@ function passesLuhn(digits: string): boolean { function redactCard(value: string): string { // Match 13–19 digits, optionally separated by spaces or hyphens in groups of 4. - return value.replace( - /\b(\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{1,7}|\d{13,19})\b/g, - (match) => { - const digits = match.replace(/[\s-]/g, ''); - if (digits.length < 13 || digits.length > 19) return match; - if (!passesLuhn(digits)) return match; - return REDACTED; - }, - ); + return value.replace(/\b(\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{1,7}|\d{13,19})\b/g, (match) => { + const digits = match.replace(/[\s-]/g, ''); + if (digits.length < 13 || digits.length > 19) return match; + if (!passesLuhn(digits)) return match; + return REDACTED; + }); } // --------------------------------------------------------------------------- diff --git a/packages/tasks/src/dev-client.ts b/packages/tasks/src/dev-client.ts new file mode 100644 index 0000000..cff31d4 --- /dev/null +++ b/packages/tasks/src/dev-client.ts @@ -0,0 +1,111 @@ +/** + * InMemoryTasksClient — deterministic in-memory TasksClient for local dev. + * + * Holds tasks in memory partitioned by `sub` so a user only ever sees and + * mutates their own tasks (mirroring the real ABAC LeadingKeys boundary). + * Seeded with a couple of FAKE tasks. No AWS, no network. + * + * This is for build-plan §4 "in-memory dev clients (tools return real data + * locally)". + */ + +import type { + CompleteTaskInput, + CreateTaskInput, + DeleteTaskInput, + ListTasksInput, + Task, + TasksClient, +} from './client.js'; + +function seedTasks(): Map { + return new Map([ + [ + 'lauren@seahavenind.com', + [ + { + taskId: 'task-0001', + sub: 'lauren@seahavenind.com', + title: 'Order replacement dock breakers', + description: 'Confirm 30A marine-grade rating before ordering.', + completed: false, + createdAt: '2026-06-20T08:00:00Z', + }, + { + taskId: 'task-0002', + sub: 'lauren@seahavenind.com', + title: 'Send insurance cert to new vendor', + completed: true, + createdAt: '2026-06-18T09:30:00Z', + completedAt: '2026-06-19T10:15:00Z', + }, + ], + ], + [ + 'adam@seahavenind.com', + [ + { + taskId: 'task-0003', + sub: 'adam@seahavenind.com', + title: 'Review Q2 vendor balances in QBO', + completed: false, + createdAt: '2026-06-22T13:00:00Z', + }, + ], + ], + ]); +} + +/** In-memory TasksClient partitioned by sub. */ +export class InMemoryTasksClient implements TasksClient { + private readonly tasksBySub: Map; + private counter = 1000; + + constructor() { + this.tasksBySub = seedTasks(); + } + + async createTask(input: CreateTaskInput): Promise { + const task: Task = { + taskId: `task-${(++this.counter).toString().padStart(4, '0')}`, + sub: input.sub, + title: input.title, + completed: false, + createdAt: new Date().toISOString(), + }; + if (input.description !== undefined) { + task.description = input.description; + } + const list = this.tasksBySub.get(input.sub) ?? []; + list.push(task); + this.tasksBySub.set(input.sub, list); + return task; + } + + async listTasks(input: ListTasksInput): Promise { + const list = this.tasksBySub.get(input.sub) ?? []; + if (input.includeCompleted === true) { + return [...list]; + } + return list.filter((t) => !t.completed); + } + + async completeTask(input: CompleteTaskInput): Promise { + const list = this.tasksBySub.get(input.sub) ?? []; + const task = list.find((t) => t.taskId === input.taskId); + if (!task) { + throw new Error(`Task ${input.taskId} not found for user ${input.sub}`); + } + task.completed = true; + task.completedAt = new Date().toISOString(); + return task; + } + + async deleteTask(input: DeleteTaskInput): Promise { + const list = this.tasksBySub.get(input.sub) ?? []; + const idx = list.findIndex((t) => t.taskId === input.taskId); + if (idx !== -1) { + list.splice(idx, 1); + } + } +} diff --git a/packages/tasks/src/index.ts b/packages/tasks/src/index.ts index 376a666..55d1879 100644 --- a/packages/tasks/src/index.ts +++ b/packages/tasks/src/index.ts @@ -11,23 +11,34 @@ */ export { buildTaskTools } from './tools.js'; -export type { TasksClient, Task, CreateTaskInput, ListTasksInput, CompleteTaskInput, DeleteTaskInput } from './client.js'; +export type { + TasksClient, + Task, + CreateTaskInput, + ListTasksInput, + CompleteTaskInput, + DeleteTaskInput, +} from './client.js'; export { DynamoDBTasksClient } from './client.js'; +export { InMemoryTasksClient } from './dev-client.js'; -// The `tools` export is the live array used by the MCP server at runtime. -// It is constructed with the real DynamoDB client, which guard-throws in test -// environments to ensure tests always go through buildTaskTools(mockClient). +// LAZY default-tools accessor. +// +// Phase 1 (build-plan §7) forbids I/O / AWS-client construction at import time. +// The previous eager `export const tools = buildTaskTools(new DynamoDBTasksClient())` +// constructed a live DynamoDB client the moment the barrel was imported, which +// broke server startup. Construction is now deferred to first call so importing +// `@sh-mcp/tasks` is side-effect-free. Servers wire tools via `buildTaskTools` +// with their selected client (dev vs real); this accessor is a convenience only. import { buildTaskTools } from './tools.js'; import { DynamoDBTasksClient } from './client.js'; -// Only instantiate the real client outside of test environments. -// In test environments, tests import buildTaskTools directly and inject a mock. -const _client = - process.env['NODE_ENV'] === 'test' - ? null - : new DynamoDBTasksClient(); +let _defaultTools: ReturnType | undefined; -export const tools = - _client !== null - ? buildTaskTools(_client) - : ([] as unknown as ReturnType); +/** Build (once) the default tools wired to the real DynamoDB client. */ +export function getDefaultTools(): ReturnType { + if (!_defaultTools) { + _defaultTools = buildTaskTools(new DynamoDBTasksClient()); + } + return _defaultTools; +} diff --git a/packages/tasks/src/tools.ts b/packages/tasks/src/tools.ts index 4abb539..d7644f2 100644 --- a/packages/tasks/src/tools.ts +++ b/packages/tasks/src/tools.ts @@ -26,7 +26,15 @@ export function buildTaskTools(client: TasksClient) { // ------------------------------------------------------------------------- const createTask = defineTool< { title: string; description?: string }, - { task: { taskId: string; title: string; description?: string; completed: boolean; createdAt: string } } + { + task: { + taskId: string; + title: string; + description?: string; + completed: boolean; + createdAt: string; + }; + } >({ name: 'create_task', description: @@ -75,7 +83,16 @@ export function buildTaskTools(client: TasksClient) { // ------------------------------------------------------------------------- const listTasks = defineTool< { includeCompleted?: boolean }, - { tasks: Array<{ taskId: string; title: string; description?: string; completed: boolean; createdAt: string; completedAt?: string }> } + { + tasks: Array<{ + taskId: string; + title: string; + description?: string; + completed: boolean; + createdAt: string; + completedAt?: string; + }>; + } >({ name: 'list_tasks', description: