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.
This commit is contained in:
Adam Moussa 2026-06-24 20:42:20 -04:00
parent c85789b75e
commit 2e16c306b0
33 changed files with 1051 additions and 237 deletions

View file

@ -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<AvailabilityResult>;
checkAvailability(userSub: string, opts: CheckAvailabilityOptions): Promise<AvailabilityResult>;
/**
* Creates a calendar event and returns the created event.

View file

@ -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<string, CalendarEvent[]> {
const map = new Map<string, CalendarEvent[]>();
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<string, CalendarEvent[]>;
private counter = 1000;
constructor() {
this.eventsBySub = seedEvents();
}
async getEvents(userSub: string, opts: GetEventsOptions): Promise<CalendarEvent[]> {
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<AvailabilityResult> {
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<CalendarEvent> {
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;
}
}

View file

@ -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,

View file

@ -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<CheckAvailabilityInput, CheckAvailabilityOutput>({
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',
},
},

View file

@ -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<string, EmailThread>;
}
function seedInboxes(): Map<string, SeedInbox> {
const map = new Map<string, SeedInbox>();
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<string, EmailThread>([
[
'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<string, EmailThread>([
[
'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<string, SeedInbox>;
constructor() {
this.inboxes = seedInboxes();
}
async searchInbox(params: SearchInboxParams): Promise<EmailMessage[]> {
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<EmailThread> {
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;
}
}

View file

@ -15,6 +15,7 @@
*/
export { GmailApiClient } from './client.js';
export { InMemoryGmailClient } from './dev-client.js';
export type {
GmailClient,
GoogleTokenProvider,

View file

@ -54,7 +54,7 @@ export function makeSearchInboxTool(client: GmailClient) {
return defineTool<SearchInboxInput, SearchInboxOutput>({
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',

View file

@ -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<PlaceResult[]> {
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);
}
}

View file

@ -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<unknown, unknown>[] {
return [
makeSearchNearbyVendors(client) as ToolDef<unknown, unknown>,
];
export function makeTools(client: GoogleMapsClient): ToolDef<unknown, unknown>[] {
return [makeSearchNearbyVendors(client) as ToolDef<unknown, unknown>];
}

View file

@ -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<any> => 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<any> => 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<any> => 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<any> => 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

View file

@ -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<string, WorkOrderRecord> {
return new Map<string, WorkOrderRecord>([
[
'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<string, PurchaseOrderRecord> {
return new Map<string, PurchaseOrderRecord>([
[
'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<string, SiteRecord> {
return new Map<string, SiteRecord>([
[
'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<WorkOrderRecord | null> {
return this.workOrders.get(workOrderId) ?? null;
}
async getPurchaseOrder(purchaseOrderId: string): Promise<PurchaseOrderRecord | null> {
return this.purchaseOrders.get(purchaseOrderId) ?? null;
}
async getSite(siteId: string): Promise<SiteRecord | null> {
return this.sites.get(siteId) ?? null;
}
}

View file

@ -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';

View file

@ -198,10 +198,7 @@ export function makeTools(client: InternalDataClient) {
},
},
},
handler: async (
input: LookupSiteInput,
ctx: AuthContext,
): Promise<LookupSiteOutput> => {
handler: async (input: LookupSiteInput, ctx: AuthContext): Promise<LookupSiteOutput> => {
requireScope(ctx, 'ops:read');
const record = await client.getSite(input.siteId);

View file

@ -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<any> => new Function('s', 'return import(s)')(s) as Promise<any>;
const dynImport = (s: string): Promise<any> =>
new Function('s', 'return import(s)')(s) as Promise<any>;
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<BedrockRetrieveResponse> };
BedrockAgentRuntimeClient: new (cfg: { region: string }) => {
send: (cmd: any) => Promise<BedrockRetrieveResponse>;
};
// 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({

View file

@ -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<KnowledgeBaseResult[]> {
const limit = options.maxResults ?? 5;
return SEED_RESULTS.slice(0, limit);
}
}

View file

@ -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,

View file

@ -54,12 +54,9 @@ export interface SearchKnowledgeBaseOutput {
* const tools = createKnowledgeBaseTools(mockClient);
*/
export function createKnowledgeBaseTools(
client: KnowledgeBaseClient
client: KnowledgeBaseClient,
): ToolDef<SearchKnowledgeBaseInput, SearchKnowledgeBaseOutput>[] {
const searchKnowledgeBase = defineTool<
SearchKnowledgeBaseInput,
SearchKnowledgeBaseOutput
>({
const searchKnowledgeBase = defineTool<SearchKnowledgeBaseInput, SearchKnowledgeBaseOutput>({
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<SearchKnowledgeBaseOutput> {
// Server-side scope enforcement — never rely solely on UI tool-hiding.
requireScope(ctx, 'ops:read');

View file

@ -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<Payment[]>;
getByVendor(vendor: string, opts?: PaymentsQueryOptions): Promise<Payment[]>;
/** Return the payment(s) matching an invoice number. */
getByInvoice(
invoiceNumber: string,
opts?: PaymentsQueryOptions,
): Promise<Payment[]>;
getByInvoice(invoiceNumber: string, opts?: PaymentsQueryOptions): Promise<Payment[]>;
/** Return the payment matching a check number. */
getByCheck(
checkNumber: string,
opts?: PaymentsQueryOptions,
): Promise<Payment[]>;
getByCheck(checkNumber: string, opts?: PaymentsQueryOptions): Promise<Payment[]>;
}
// ---------------------------------------------------------------------------
@ -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<Payment[]> {
async getByVendor(_vendor: string, _opts?: PaymentsQueryOptions): Promise<Payment[]> {
// 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<Payment[]> {
async getByInvoice(_invoiceNumber: string, _opts?: PaymentsQueryOptions): Promise<Payment[]> {
// 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<Payment[]> {
async getByCheck(_checkNumber: string, _opts?: PaymentsQueryOptions): Promise<Payment[]> {
// 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.');
}
}

View file

@ -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<Payment[]> {
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<Payment[]> {
const matches = this.payments.filter((p) => p.invoiceNumber === invoiceNumber);
return this.applyLimit(matches, opts);
}
async getByCheck(checkNumber: string, opts?: PaymentsQueryOptions): Promise<Payment[]> {
const matches = this.payments.filter((p) => p.checkNumber === checkNumber);
return this.applyLimit(matches, opts);
}
}

View file

@ -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.

View file

@ -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<LookupByVendorInput, LookupByVendorOutput>({
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<LookupByVendorOutput> {
requireScope(ctx, "finance:read");
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 });
@ -126,30 +122,27 @@ export interface LookupByInvoiceOutput {
export function makeLookupByInvoiceTool(client: PaymentsClient) {
return defineTool<LookupByInvoiceInput, LookupByInvoiceOutput>({
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<LookupByInvoiceOutput> {
requireScope(ctx, "finance:read");
async handler(input: LookupByInvoiceInput, ctx: AuthContext): Promise<LookupByInvoiceOutput> {
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<LookupByCheckInput, LookupByCheckOutput>({
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<LookupByCheckOutput> {
requireScope(ctx, "finance:read");
async handler(input: LookupByCheckInput, ctx: AuthContext): Promise<LookupByCheckOutput> {
requireScope(ctx, 'finance:read');
const payments = await client.getByCheck(input.checkNumber);
const redacted = payments.map(redactPayment);

View file

@ -1,7 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"rootDir": "src",
"outDir": "./dist",
"declarationDir": "./dist"
},

View file

@ -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<SearchVendorsResult> {
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,
};
}
}

View file

@ -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<unknown, unknown>[] | 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<unknown, unknown>[] {
if (!_defaultTools) {
_defaultTools = [makeSearchVendorsTool(new QboClientImpl()) as ToolDef<unknown, unknown>];
}
return _defaultTools;
}

View file

@ -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<SearchVendorsOutput> {
async handler(input: SearchVendorsInput, ctx: AuthContext): Promise<SearchVendorsOutput> {
// 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) {

View file

@ -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.',
);
}
}

View file

@ -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<CreateScheduleOutput> {
this.created.push(input);
return {
scheduleArn: `arn:aws:scheduler:us-east-1:000000000000:schedule/dev/${input.scheduleName}`,
};
}
}

View file

@ -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<typeof buildReminderTools> | 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<typeof buildReminderTools> {
if (!_defaultTools) {
_defaultTools = buildReminderTools({
client: new AwsSchedulerClient({
targetArn: process.env['REMINDER_TARGET_ARN'] ?? '',
roleArn: process.env['SCHEDULER_ROLE_ARN'] ?? '',
}),
});
}
return _defaultTools;
}

View file

@ -78,7 +78,7 @@ export function buildReminderTools(deps: { client: SchedulerClient }) {
handler: async (
input: CreateReminderInput,
ctx: AuthContext
ctx: AuthContext,
): Promise<CreateReminderOutput> => {
// 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.

View file

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

View file

@ -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<string, Task[]> {
return new Map<string, Task[]>([
[
'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<string, Task[]>;
private counter = 1000;
constructor() {
this.tasksBySub = seedTasks();
}
async createTask(input: CreateTaskInput): Promise<Task> {
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<Task[]> {
const list = this.tasksBySub.get(input.sub) ?? [];
if (input.includeCompleted === true) {
return [...list];
}
return list.filter((t) => !t.completed);
}
async completeTask(input: CompleteTaskInput): Promise<Task> {
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<void> {
const list = this.tasksBySub.get(input.sub) ?? [];
const idx = list.findIndex((t) => t.taskId === input.taskId);
if (idx !== -1) {
list.splice(idx, 1);
}
}
}

View file

@ -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<typeof buildTaskTools> | undefined;
export const tools =
_client !== null
? buildTaskTools(_client)
: ([] as unknown as ReturnType<typeof buildTaskTools>);
/** Build (once) the default tools wired to the real DynamoDB client. */
export function getDefaultTools(): ReturnType<typeof buildTaskTools> {
if (!_defaultTools) {
_defaultTools = buildTaskTools(new DynamoDBTasksClient());
}
return _defaultTools;
}

View file

@ -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: