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; id: string;
summary: string; summary: string;
description?: string; description?: string;
start: string; // ISO-8601 datetime or date start: string; // ISO-8601 datetime or date
end: string; // ISO-8601 datetime or date end: string; // ISO-8601 datetime or date
attendees?: CalendarAttendee[]; attendees?: CalendarAttendee[];
/** True when at least one attendee is not @seahavenind.com */ /** True when at least one attendee is not @seahavenind.com */
hasExternalAttendees?: boolean; hasExternalAttendees?: boolean;
status?: string; // confirmed | tentative | cancelled status?: string; // confirmed | tentative | cancelled
htmlLink?: string; htmlLink?: string;
} }
@ -35,9 +35,9 @@ export interface CalendarAttendee {
} }
export interface GetEventsOptions { export interface GetEventsOptions {
calendarId?: string; // defaults to 'primary' calendarId?: string; // defaults to 'primary'
timeMin: string; // ISO-8601 timeMin: string; // ISO-8601
timeMax: string; // ISO-8601 timeMax: string; // ISO-8601
maxResults?: number; maxResults?: number;
singleEvents?: boolean; singleEvents?: boolean;
orderBy?: 'startTime' | 'updated'; orderBy?: 'startTime' | 'updated';
@ -58,11 +58,11 @@ export interface AvailabilityResult {
} }
export interface CreateEventOptions { export interface CreateEventOptions {
calendarId?: string; // defaults to 'primary' calendarId?: string; // defaults to 'primary'
summary: string; summary: string;
description?: string; description?: string;
start: string; // ISO-8601 datetime start: string; // ISO-8601 datetime
end: string; // ISO-8601 datetime end: string; // ISO-8601 datetime
attendees?: Array<{ email: string; displayName?: string }>; attendees?: Array<{ email: string; displayName?: string }>;
location?: string; location?: string;
timeZone?: string; timeZone?: string;
@ -82,10 +82,7 @@ export interface CalendarClient {
/** /**
* Queries free/busy information for the user's calendar. * Queries free/busy information for the user's calendar.
*/ */
checkAvailability( checkAvailability(userSub: string, opts: CheckAvailabilityOptions): Promise<AvailabilityResult>;
userSub: string,
opts: CheckAvailabilityOptions,
): Promise<AvailabilityResult>;
/** /**
* Creates a calendar event and returns the created event. * 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, CreateEventOptions,
} from './client.js'; } from './client.js';
export { GoogleCalendarClient } from './client.js'; export { GoogleCalendarClient } from './client.js';
export { InMemoryCalendarClient } from './dev-client.js';
export type { export type {
GetCalendarEventsInput, GetCalendarEventsInput,
GetCalendarEventsOutput, GetCalendarEventsOutput,

View file

@ -16,11 +16,7 @@
import { defineTool, requireScope } from '@sh-mcp/shared'; import { defineTool, requireScope } from '@sh-mcp/shared';
import type { AuthContext } from '@sh-mcp/shared'; import type { AuthContext } from '@sh-mcp/shared';
import type { import type { CalendarClient, CalendarEvent, AvailabilityResult } from './client.js';
CalendarClient,
CalendarEvent,
AvailabilityResult,
} from './client.js';
import { CalendarClientError } from './client.js'; import { CalendarClientError } from './client.js';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -131,7 +127,7 @@ export function buildCalendarTools(client: CalendarClient) {
}, },
calendarId: { calendarId: {
type: 'string', type: 'string',
description: 'Calendar ID to query. Defaults to \'primary\'.', description: "Calendar ID to query. Defaults to 'primary'.",
default: 'primary', default: 'primary',
}, },
maxResults: { maxResults: {
@ -181,7 +177,7 @@ export function buildCalendarTools(client: CalendarClient) {
const checkAvailability = defineTool<CheckAvailabilityInput, CheckAvailabilityOutput>({ const checkAvailability = defineTool<CheckAvailabilityInput, CheckAvailabilityOutput>({
name: 'check_availability', name: 'check_availability',
description: 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. ' + 'Returns a list of busy blocks and derived free blocks. ' +
'Useful for scheduling and finding open meeting slots.', 'Useful for scheduling and finding open meeting slots.',
tier: 'ops', tier: 'ops',
@ -203,7 +199,7 @@ export function buildCalendarTools(client: CalendarClient) {
}, },
calendarId: { calendarId: {
type: 'string', type: 'string',
description: 'Calendar ID to check. Defaults to \'primary\'.', description: "Calendar ID to check. Defaults to 'primary'.",
default: 'primary', default: 'primary',
}, },
}, },
@ -301,7 +297,7 @@ export function buildCalendarTools(client: CalendarClient) {
}, },
calendarId: { calendarId: {
type: 'string', type: 'string',
description: 'Calendar to create the event in. Defaults to \'primary\'.', description: "Calendar to create the event in. Defaults to 'primary'.",
default: '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 { GmailApiClient } from './client.js';
export { InMemoryGmailClient } from './dev-client.js';
export type { export type {
GmailClient, GmailClient,
GoogleTokenProvider, GoogleTokenProvider,

View file

@ -54,7 +54,7 @@ export function makeSearchInboxTool(client: GmailClient) {
return defineTool<SearchInboxInput, SearchInboxOutput>({ return defineTool<SearchInboxInput, SearchInboxOutput>({
name: 'search_inbox', name: 'search_inbox',
description: 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. ' + 'Returns matching message metadata and snippets. ' +
'Acts as the authenticated user — never reads another mailbox.', 'Acts as the authenticated user — never reads another mailbox.',
tier: 'ops', 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 { GooglePlacesClient } from './client.js';
export { InMemoryGoogleMapsClient } from './dev-client.js';
export type { GoogleMapsClient, PlaceResult, SearchTextParams } from './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'; export { makeSearchNearbyVendors } from './tools.js';
import type { ToolDef } from '@sh-mcp/shared'; 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. * Build the full tools array wired to an injected client.
* Servers call this at startup with their concrete GooglePlacesClient. * Servers call this at startup with their concrete GooglePlacesClient.
*/ */
export function makeTools( export function makeTools(client: GoogleMapsClient): ToolDef<unknown, unknown>[] {
client: GoogleMapsClient, return [makeSearchNearbyVendors(client) as ToolDef<unknown, unknown>];
): ToolDef<unknown, unknown>[] {
return [
makeSearchNearbyVendors(client) as ToolDef<unknown, unknown>,
];
} }

View file

@ -64,15 +64,15 @@ export interface InternalDataClient {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Real (AWS SDK-backed) implementation // Real (AWS SDK-backed) implementation
// //
// The AWS SDK import lives here — behind this class — so that: // The AWS SDK import lives here — behind this class — so that:
// a) Nothing happens at module load time (no credential resolution, no env reads). // 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. // 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 // 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 // role will supply credentials via the standard AWS environment variables. At that
// point ensure the DocumentClient is constructed with the correct region and that // point ensure the DocumentClient is constructed with the correct region and that
// the table names are injected via environment variables (WORK_ORDERS_TABLE, // the table names are injected via environment variables (WORK_ORDERS_TABLE,
// PURCHASE_ORDERS_TABLE, SITE_ASSIGNMENTS_TABLE) rather than hard-coded. // PURCHASE_ORDERS_TABLE, SITE_ASSIGNMENTS_TABLE) rather than hard-coded.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export class RealDynamoClient implements InternalDataClient { export class RealDynamoClient implements InternalDataClient {
@ -114,9 +114,13 @@ export class RealDynamoClient implements InternalDataClient {
const dynImport = (s: string): Promise<any> => import(/* @vite-ignore */ s); const dynImport = (s: string): Promise<any> => import(/* @vite-ignore */ s);
// eslint-disable-next-line @typescript-eslint/no-explicit-any // 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 // 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'; const region = process.env['AWS_REGION'] ?? 'us-east-1';
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment // 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any
const dynImport = (s: string): Promise<any> => import(/* @vite-ignore */ s); const dynImport = (s: string): Promise<any> => import(/* @vite-ignore */ s);
// eslint-disable-next-line @typescript-eslint/no-explicit-any // 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 // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment
const result = await this.ddb.send( const result = await this.ddb.send(
// eslint-disable-next-line @typescript-eslint/no-unsafe-call // 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any
const dynImport = (s: string): Promise<any> => import(/* @vite-ignore */ s); const dynImport = (s: string): Promise<any> => import(/* @vite-ignore */ s);
// eslint-disable-next-line @typescript-eslint/no-explicit-any // 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 // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment
const result = await this.ddb.send( const result = await this.ddb.send(
// eslint-disable-next-line @typescript-eslint/no-unsafe-call // 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any
const dynImport = (s: string): Promise<any> => import(/* @vite-ignore */ s); const dynImport = (s: string): Promise<any> => import(/* @vite-ignore */ s);
// eslint-disable-next-line @typescript-eslint/no-explicit-any // 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 // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment
const result = await this.ddb.send( const result = await this.ddb.send(
// eslint-disable-next-line @typescript-eslint/no-unsafe-call // 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, LookupSiteInput,
LookupSiteOutput, LookupSiteOutput,
} from './tools.js'; } 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 { RealDynamoClient } from './client.js';
export { InMemoryInternalDataClient } from './dev-client.js';

View file

@ -198,10 +198,7 @@ export function makeTools(client: InternalDataClient) {
}, },
}, },
}, },
handler: async ( handler: async (input: LookupSiteInput, ctx: AuthContext): Promise<LookupSiteOutput> => {
input: LookupSiteInput,
ctx: AuthContext,
): Promise<LookupSiteOutput> => {
requireScope(ctx, 'ops:read'); requireScope(ctx, 'ops:read');
const record = await client.getSite(input.siteId); 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 // until the Lambda runtime bundle is assembled. The module specifier is stored in a
// variable so TypeScript skips static module resolution at build time. // variable so TypeScript skips static module resolution at build time.
// eslint-disable-next-line @typescript-eslint/no-explicit-any // 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 { interface BedrockRetrievalResult {
location?: { s3Location?: { uri?: string }; type?: string }; location?: { s3Location?: { uri?: string }; type?: string };
@ -97,9 +98,13 @@ export class BedrockKnowledgeBaseClient implements KnowledgeBaseClient {
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // 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 // 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any
RetrieveCommand: new (input: any) => unknown; RetrieveCommand: new (input: any) => unknown;
}; };
@ -140,7 +145,7 @@ export function createBedrockClientFromEnv(): BedrockKnowledgeBaseClient {
const knowledgeBaseId = process.env['KNOWLEDGE_BASE_ID']; const knowledgeBaseId = process.env['KNOWLEDGE_BASE_ID'];
if (!knowledgeBaseId) { if (!knowledgeBaseId) {
throw new Error( 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({ 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, SearchKnowledgeBaseResultItem,
} from './tools.js'; } from './tools.js';
export { export { BedrockKnowledgeBaseClient, createBedrockClientFromEnv } from './client.js';
BedrockKnowledgeBaseClient, export { InMemoryKnowledgeBaseClient } from './dev-client.js';
createBedrockClientFromEnv,
} from './client.js';
export type { export type {
KnowledgeBaseClient, KnowledgeBaseClient,
KnowledgeBaseResult, KnowledgeBaseResult,

View file

@ -54,12 +54,9 @@ export interface SearchKnowledgeBaseOutput {
* const tools = createKnowledgeBaseTools(mockClient); * const tools = createKnowledgeBaseTools(mockClient);
*/ */
export function createKnowledgeBaseTools( export function createKnowledgeBaseTools(
client: KnowledgeBaseClient client: KnowledgeBaseClient,
): ToolDef<SearchKnowledgeBaseInput, SearchKnowledgeBaseOutput>[] { ): ToolDef<SearchKnowledgeBaseInput, SearchKnowledgeBaseOutput>[] {
const searchKnowledgeBase = defineTool< const searchKnowledgeBase = defineTool<SearchKnowledgeBaseInput, SearchKnowledgeBaseOutput>({
SearchKnowledgeBaseInput,
SearchKnowledgeBaseOutput
>({
name: 'search_knowledge_base', name: 'search_knowledge_base',
description: description:
'Search the Sea Haven internal knowledge base for relevant information. ' + 'Search the Sea Haven internal knowledge base for relevant information. ' +
@ -73,15 +70,13 @@ export function createKnowledgeBaseTools(
properties: { properties: {
query: { query: {
type: 'string', type: 'string',
description: description: 'Natural-language question or keyword search query.',
'Natural-language question or keyword search query.',
minLength: 1, minLength: 1,
maxLength: 1000, maxLength: 1000,
}, },
maxResults: { maxResults: {
type: 'integer', type: 'integer',
description: description: 'Maximum number of passages to return (1–20). Defaults to 5.',
'Maximum number of passages to return (1–20). Defaults to 5.',
minimum: 1, minimum: 1,
maximum: 20, maximum: 20,
default: 5, default: 5,
@ -93,7 +88,7 @@ export function createKnowledgeBaseTools(
async handler( async handler(
input: SearchKnowledgeBaseInput, input: SearchKnowledgeBaseInput,
ctx: AuthContext ctx: AuthContext,
): Promise<SearchKnowledgeBaseOutput> { ): Promise<SearchKnowledgeBaseOutput> {
// Server-side scope enforcement — never rely solely on UI tool-hiding. // Server-side scope enforcement — never rely solely on UI tool-hiding.
requireScope(ctx, 'ops:read'); requireScope(ctx, 'ops:read');

View file

@ -22,7 +22,7 @@ export interface Payment {
checkNumber?: string; checkNumber?: string;
/** ISO-8601 date string */ /** ISO-8601 date string */
paymentDate: string; paymentDate: string;
status: "pending" | "cleared" | "voided" | "failed"; status: 'pending' | 'cleared' | 'voided' | 'failed';
/** Bank account number — MUST be redacted before leaving the server */ /** Bank account number — MUST be redacted before leaving the server */
bankAccountNumber?: string; bankAccountNumber?: string;
/** Bank routing number — MUST be redacted before leaving the server */ /** Bank routing number — MUST be redacted before leaving the server */
@ -43,22 +43,13 @@ export interface PaymentsQueryOptions {
*/ */
export interface PaymentsClient { export interface PaymentsClient {
/** Return all payments for a given vendor name (case-insensitive prefix match). */ /** Return all payments for a given vendor name (case-insensitive prefix match). */
getByVendor( getByVendor(vendor: string, opts?: PaymentsQueryOptions): Promise<Payment[]>;
vendor: string,
opts?: PaymentsQueryOptions,
): Promise<Payment[]>;
/** Return the payment(s) matching an invoice number. */ /** Return the payment(s) matching an invoice number. */
getByInvoice( getByInvoice(invoiceNumber: string, opts?: PaymentsQueryOptions): Promise<Payment[]>;
invoiceNumber: string,
opts?: PaymentsQueryOptions,
): Promise<Payment[]>;
/** Return the payment matching a check number. */ /** Return the payment matching a check number. */
getByCheck( getByCheck(checkNumber: string, opts?: PaymentsQueryOptions): Promise<Payment[]>;
checkNumber: string,
opts?: PaymentsQueryOptions,
): Promise<Payment[]>;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -82,7 +73,7 @@ export interface PaymentsClient {
export class DynamoPaymentsClient implements PaymentsClient { export class DynamoPaymentsClient implements PaymentsClient {
private readonly tableName: string; private readonly tableName: string;
constructor(tableName = process.env["PAYMENTS_TABLE"] ?? "PaymentsDashboard") { constructor(tableName = process.env['PAYMENTS_TABLE'] ?? 'PaymentsDashboard') {
this.tableName = tableName; this.tableName = tableName;
// The DynamoDB DocumentClient is intentionally NOT instantiated here to // The DynamoDB DocumentClient is intentionally NOT instantiated here to
// avoid any AWS SDK import side-effects at module load time. Instantiate // 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 void this.tableName; // suppress unused-var lint until real impl lands
} }
async getByVendor( async getByVendor(_vendor: string, _opts?: PaymentsQueryOptions): Promise<Payment[]> {
_vendor: string,
_opts?: PaymentsQueryOptions,
): Promise<Payment[]> {
// TODO(real-impl): query VendorIndex GSI with vendor_normalized = vendor.toLowerCase() // TODO(real-impl): query VendorIndex GSI with vendor_normalized = vendor.toLowerCase()
throw new Error( throw new Error('DynamoPaymentsClient is a stub — inject a real or mock client instead.');
"DynamoPaymentsClient is a stub — inject a real or mock client instead.",
);
} }
async getByInvoice( async getByInvoice(_invoiceNumber: string, _opts?: PaymentsQueryOptions): Promise<Payment[]> {
_invoiceNumber: string,
_opts?: PaymentsQueryOptions,
): Promise<Payment[]> {
// TODO(real-impl): query InvoiceIndex GSI with invoiceNumber = invoiceNumber // TODO(real-impl): query InvoiceIndex GSI with invoiceNumber = invoiceNumber
throw new Error( throw new Error('DynamoPaymentsClient is a stub — inject a real or mock client instead.');
"DynamoPaymentsClient is a stub — inject a real or mock client instead.",
);
} }
async getByCheck( async getByCheck(_checkNumber: string, _opts?: PaymentsQueryOptions): Promise<Payment[]> {
_checkNumber: string,
_opts?: PaymentsQueryOptions,
): Promise<Payment[]> {
// TODO(real-impl): query CheckIndex GSI with checkNumber = checkNumber // TODO(real-impl): query CheckIndex GSI with checkNumber = checkNumber
throw new Error( throw new Error('DynamoPaymentsClient is a stub — inject a real or mock client instead.');
"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 * // register tools with the server registry
*/ */
export type { PaymentsClient, Payment, PaymentsQueryOptions } from "./client.js"; export type { PaymentsClient, Payment, PaymentsQueryOptions } from './client.js';
export { DynamoPaymentsClient } from "./client.js"; export { DynamoPaymentsClient } from './client.js';
export { InMemoryPaymentsClient } from './dev-client.js';
export type { export type {
LookupByVendorInput, LookupByVendorInput,
@ -23,19 +24,11 @@ export type {
LookupByInvoiceOutput, LookupByInvoiceOutput,
LookupByCheckInput, LookupByCheckInput,
LookupByCheckOutput, LookupByCheckOutput,
} from "./tools.js"; } from './tools.js';
export { export { makeLookupByVendorTool, makeLookupByInvoiceTool, makeLookupByCheckTool } from './tools.js';
makeLookupByVendorTool,
makeLookupByInvoiceTool,
makeLookupByCheckTool,
} from "./tools.js";
import type { PaymentsClient } from "./client.js"; import type { PaymentsClient } from './client.js';
import { import { makeLookupByVendorTool, makeLookupByInvoiceTool, makeLookupByCheckTool } from './tools.js';
makeLookupByVendorTool,
makeLookupByInvoiceTool,
makeLookupByCheckTool,
} from "./tools.js";
/** /**
* Build the full payments tool array for a given client. * 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. * that must remain authoritative even after the middleware is in place.
*/ */
import { defineTool, requireScope, redact, maskValue } from "@sh-mcp/shared"; import { defineTool, requireScope, redact, maskValue } from '@sh-mcp/shared';
import type { AuthContext } from "@sh-mcp/shared"; import type { AuthContext } from '@sh-mcp/shared';
import type { PaymentsClient, Payment } from "./client.js"; import type { PaymentsClient, Payment } from './client.js';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Shared output shaping // Shared output shaping
@ -68,39 +68,35 @@ export interface LookupByVendorOutput {
export function makeLookupByVendorTool(client: PaymentsClient) { export function makeLookupByVendorTool(client: PaymentsClient) {
return defineTool<LookupByVendorInput, LookupByVendorOutput>({ return defineTool<LookupByVendorInput, LookupByVendorOutput>({
name: "lookup_payment_by_vendor", name: 'lookup_payment_by_vendor',
description: description:
"Look up PaymentsDashboard records for a given vendor name. " + 'Look up PaymentsDashboard records for a given vendor name. ' +
"Returns cleared, pending, voided, and failed payments. " + 'Returns cleared, pending, voided, and failed payments. ' +
"Sensitive bank, routing, and card fields are masked in the response.", 'Sensitive bank, routing, and card fields are masked in the response.',
tier: "finance", tier: 'finance',
requiredScope: "finance:read", requiredScope: 'finance:read',
inputSchema: { inputSchema: {
type: "object", type: 'object',
properties: { properties: {
vendor: { vendor: {
type: "string", type: 'string',
description: description: 'Vendor name to search for (case-insensitive prefix match).',
"Vendor name to search for (case-insensitive prefix match).",
minLength: 1, minLength: 1,
maxLength: 200, maxLength: 200,
}, },
limit: { limit: {
type: "integer", type: 'integer',
description: "Maximum number of results to return (1–100). Defaults to 20.", description: 'Maximum number of results to return (1–100). Defaults to 20.',
minimum: 1, minimum: 1,
maximum: 100, maximum: 100,
default: 20, default: 20,
}, },
}, },
required: ["vendor"], required: ['vendor'],
additionalProperties: false, additionalProperties: false,
}, },
async handler( async handler(input: LookupByVendorInput, ctx: AuthContext): Promise<LookupByVendorOutput> {
input: LookupByVendorInput, requireScope(ctx, 'finance:read');
ctx: AuthContext,
): Promise<LookupByVendorOutput> {
requireScope(ctx, "finance:read");
const limit = Math.min(input.limit ?? 20, 100); const limit = Math.min(input.limit ?? 20, 100);
const payments = await client.getByVendor(input.vendor, { limit }); const payments = await client.getByVendor(input.vendor, { limit });
@ -126,30 +122,27 @@ export interface LookupByInvoiceOutput {
export function makeLookupByInvoiceTool(client: PaymentsClient) { export function makeLookupByInvoiceTool(client: PaymentsClient) {
return defineTool<LookupByInvoiceInput, LookupByInvoiceOutput>({ return defineTool<LookupByInvoiceInput, LookupByInvoiceOutput>({
name: "lookup_payment_by_invoice", name: 'lookup_payment_by_invoice',
description: description:
"Look up a payment in PaymentsDashboard by invoice number. " + 'Look up a payment in PaymentsDashboard by invoice number. ' +
"Sensitive bank, routing, and card fields are masked in the response.", 'Sensitive bank, routing, and card fields are masked in the response.',
tier: "finance", tier: 'finance',
requiredScope: "finance:read", requiredScope: 'finance:read',
inputSchema: { inputSchema: {
type: "object", type: 'object',
properties: { properties: {
invoiceNumber: { invoiceNumber: {
type: "string", type: 'string',
description: "The invoice number to look up (exact match).", description: 'The invoice number to look up (exact match).',
minLength: 1, minLength: 1,
maxLength: 100, maxLength: 100,
}, },
}, },
required: ["invoiceNumber"], required: ['invoiceNumber'],
additionalProperties: false, additionalProperties: false,
}, },
async handler( async handler(input: LookupByInvoiceInput, ctx: AuthContext): Promise<LookupByInvoiceOutput> {
input: LookupByInvoiceInput, requireScope(ctx, 'finance:read');
ctx: AuthContext,
): Promise<LookupByInvoiceOutput> {
requireScope(ctx, "finance:read");
const payments = await client.getByInvoice(input.invoiceNumber); const payments = await client.getByInvoice(input.invoiceNumber);
const redacted = payments.map(redactPayment); const redacted = payments.map(redactPayment);
@ -174,30 +167,27 @@ export interface LookupByCheckOutput {
export function makeLookupByCheckTool(client: PaymentsClient) { export function makeLookupByCheckTool(client: PaymentsClient) {
return defineTool<LookupByCheckInput, LookupByCheckOutput>({ return defineTool<LookupByCheckInput, LookupByCheckOutput>({
name: "lookup_payment_by_check", name: 'lookup_payment_by_check',
description: description:
"Look up a payment in PaymentsDashboard by check number. " + 'Look up a payment in PaymentsDashboard by check number. ' +
"Sensitive bank, routing, and card fields are masked in the response.", 'Sensitive bank, routing, and card fields are masked in the response.',
tier: "finance", tier: 'finance',
requiredScope: "finance:read", requiredScope: 'finance:read',
inputSchema: { inputSchema: {
type: "object", type: 'object',
properties: { properties: {
checkNumber: { checkNumber: {
type: "string", type: 'string',
description: "The check number to look up (exact match).", description: 'The check number to look up (exact match).',
minLength: 1, minLength: 1,
maxLength: 50, maxLength: 50,
}, },
}, },
required: ["checkNumber"], required: ['checkNumber'],
additionalProperties: false, additionalProperties: false,
}, },
async handler( async handler(input: LookupByCheckInput, ctx: AuthContext): Promise<LookupByCheckOutput> {
input: LookupByCheckInput, requireScope(ctx, 'finance:read');
ctx: AuthContext,
): Promise<LookupByCheckOutput> {
requireScope(ctx, "finance:read");
const payments = await client.getByCheck(input.checkNumber); const payments = await client.getByCheck(input.checkNumber);
const redacted = payments.map(redactPayment); const redacted = payments.map(redactPayment);

View file

@ -1,7 +1,7 @@
{ {
"extends": "../../tsconfig.base.json", "extends": "../../tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"rootDir": ".", "rootDir": "src",
"outDir": "./dist", "outDir": "./dist",
"declarationDir": "./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 { makeSearchVendorsTool } from './tools.js';
export type { SearchVendorsInput, SearchVendorsOutput, VendorRecord } 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 { QboClientImpl, QboApiError, QboThrottleError } from './client.js';
export { InMemoryQboClient } from './dev-client.js';
import { makeSearchVendorsTool } from './tools.js'; import { makeSearchVendorsTool } from './tools.js';
import { QboClientImpl } from './client.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) * LAZY by design (build-plan §7): construction is deferred to first call so
* until the real OAuth/Secrets Manager integration is built (see client.ts * importing `@sh-mcp/qbo` is side-effect-free. `QboClientImpl.searchVendors` is
* TODO). For production use, instantiate QboClientImpl only after the * stubbed (throws) until the real OAuth/Secrets integration lands; servers wire
* DEFERRED auth layer is in place, or inject your own QboClientInterface. * 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: { query: {
type: 'string', type: 'string',
description: description:
'Search term matched against vendor display name. ' + 'Search term matched against vendor display name. ' + 'Case-insensitive substring match.',
'Case-insensitive substring match.',
minLength: 1, minLength: 1,
maxLength: 200, maxLength: 200,
}, },
@ -93,10 +92,7 @@ export function makeSearchVendorsTool(client: QboClientInterface) {
requiredScope: 'finance:read', requiredScope: 'finance:read',
inputSchema: searchVendorsInputSchema, inputSchema: searchVendorsInputSchema,
async handler( async handler(input: SearchVendorsInput, ctx: AuthContext): Promise<SearchVendorsOutput> {
input: SearchVendorsInput,
ctx: AuthContext,
): Promise<SearchVendorsOutput> {
// Server-side scope enforcement — authoritative, not a UI hint. // Server-side scope enforcement — authoritative, not a UI hint.
requireScope(ctx, 'finance:read'); requireScope(ctx, 'finance:read');
@ -112,9 +108,7 @@ export function makeSearchVendorsTool(client: QboClientInterface) {
if (err instanceof QboThrottleError) { if (err instanceof QboThrottleError) {
// Surface throttle detail so callers can back off. // Surface throttle detail so callers can back off.
const waitHint = const waitHint =
err.retryAfterSeconds !== undefined err.retryAfterSeconds !== undefined ? ` Retry after ${err.retryAfterSeconds}s.` : '';
? ` Retry after ${err.retryAfterSeconds}s.`
: '';
throw new Error(`QBO rate limit exceeded.${waitHint}`); throw new Error(`QBO rate limit exceeded.${waitHint}`);
} }
if (err instanceof QboApiError) { if (err instanceof QboApiError) {

View file

@ -62,11 +62,7 @@ export class AwsSchedulerClient implements SchedulerClient {
private readonly _targetArn: string; private readonly _targetArn: string;
private readonly _roleArn: string; private readonly _roleArn: string;
constructor(opts: { constructor(opts: { region?: string; targetArn: string; roleArn: string }) {
region?: string;
targetArn: string;
roleArn: string;
}) {
this._region = opts.region ?? process.env['AWS_REGION'] ?? 'us-east-1'; this._region = opts.region ?? process.env['AWS_REGION'] ?? 'us-east-1';
this._targetArn = opts.targetArn; this._targetArn = opts.targetArn;
this._roleArn = opts.roleArn; this._roleArn = opts.roleArn;
@ -104,7 +100,7 @@ export class AwsSchedulerClient implements SchedulerClient {
throw new Error( throw new Error(
'AwsSchedulerClient.createSchedule: production AWS SDK call is not yet wired. ' + 'AwsSchedulerClient.createSchedule: production AWS SDK call is not yet wired. ' +
'Inject a SchedulerClient mock in tests, or complete the IAM cross-review and ' + '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 { CreateReminderInput, CreateReminderOutput } from './tools.js';
export type { SchedulerClient, CreateScheduleInput, CreateScheduleOutput } from './client.js'; export type { SchedulerClient, CreateScheduleInput, CreateScheduleOutput } from './client.js';
export { AwsSchedulerClient } from './client.js'; export { AwsSchedulerClient } from './client.js';
export { InMemorySchedulerClient } from './dev-client.js';
import { buildReminderTools } from './tools.js'; import { buildReminderTools } from './tools.js';
import { AwsSchedulerClient } from './client.js'; import { AwsSchedulerClient } from './client.js';
let _defaultTools: ReturnType<typeof buildReminderTools> | undefined;
/** /**
* Default tool array — uses the production AwsSchedulerClient. * Build (once) the default tools wired to 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. * 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({ export function getDefaultTools(): ReturnType<typeof buildReminderTools> {
client: new AwsSchedulerClient({ if (!_defaultTools) {
targetArn: process.env['REMINDER_TARGET_ARN'] ?? '', _defaultTools = buildReminderTools({
roleArn: process.env['SCHEDULER_ROLE_ARN'] ?? '', 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 ( handler: async (
input: CreateReminderInput, input: CreateReminderInput,
ctx: AuthContext ctx: AuthContext,
): Promise<CreateReminderOutput> => { ): Promise<CreateReminderOutput> => {
// Enforce the required scope server-side on every invocation. // Enforce the required scope server-side on every invocation.
// requireScope throws ScopeError if the scope is missing. // requireScope throws ScopeError if the scope is missing.
@ -93,18 +93,15 @@ export function buildReminderTools(deps: { client: SchedulerClient }) {
if (isNaN(fireAt.getTime())) { if (isNaN(fireAt.getTime())) {
throw new Error( throw new Error(
`create_reminder: invalid remind_at value "${input.remind_at}". ` + `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) { if (fireAt.getTime() <= Date.now() + 60_000) {
throw new Error( throw new Error('create_reminder: remind_at must be at least 1 minute in the future.');
'create_reminder: remind_at must be at least 1 minute in the future.'
);
} }
// Resolve the recipient: default to calling user sub. // Resolve the recipient: default to calling user sub.
const recipient = const recipient = !input.recipient || input.recipient === 'me' ? ctx.sub : input.recipient;
!input.recipient || input.recipient === 'me' ? ctx.sub : input.recipient;
// Derive a safe, unique schedule name from the user sub + timestamp. // Derive a safe, unique schedule name from the user sub + timestamp.
// EventBridge schedule names: [a-zA-Z0-9_-], max 64 chars. // 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 { function redactSSN(value: string): string {
// Canonical and spaced formats (unambiguous) // Canonical and spaced formats (unambiguous)
let result = value.replace( let result = value.replace(/\b(\d{3})[- ](\d{2})[- ](\d{4})\b/g, REDACTED);
/\b(\d{3})[- ](\d{2})[- ](\d{4})\b/g,
REDACTED,
);
// Bare 9-digit SSN preceded by an SSN keyword // Bare 9-digit SSN preceded by an SSN keyword
result = result.replace( result = result.replace(
/\b(ssn|social\s+security(?:\s+number)?|tax\s+id)\s*[:#]?\s*(\d{9})\b/gi, /\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}`, (_match, keyword) => `${keyword.trim()} ${REDACTED}`,
); );
// Keyword AFTER the number: "021000021 (routing)" // Keyword AFTER the number: "021000021 (routing)"
result = result.replace( result = result.replace(/\b(\d{9})\s*\((routing|aba)\)/gi, `${REDACTED} ($2)`);
/\b(\d{9})\s*\((routing|aba)\)/gi,
`${REDACTED} ($2)`,
);
return result; return result;
} }
@ -116,15 +110,12 @@ function passesLuhn(digits: string): boolean {
function redactCard(value: string): string { function redactCard(value: string): string {
// Match 13–19 digits, optionally separated by spaces or hyphens in groups of 4. // Match 13–19 digits, optionally separated by spaces or hyphens in groups of 4.
return value.replace( return value.replace(/\b(\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{1,7}|\d{13,19})\b/g, (match) => {
/\b(\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{1,7}|\d{13,19})\b/g, const digits = match.replace(/[\s-]/g, '');
(match) => { if (digits.length < 13 || digits.length > 19) return match;
const digits = match.replace(/[\s-]/g, ''); if (!passesLuhn(digits)) return match;
if (digits.length < 13 || digits.length > 19) return match; return REDACTED;
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 { 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 { DynamoDBTasksClient } from './client.js';
export { InMemoryTasksClient } from './dev-client.js';
// The `tools` export is the live array used by the MCP server at runtime. // LAZY default-tools accessor.
// It is constructed with the real DynamoDB client, which guard-throws in test //
// environments to ensure tests always go through buildTaskTools(mockClient). // 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 { buildTaskTools } from './tools.js';
import { DynamoDBTasksClient } from './client.js'; import { DynamoDBTasksClient } from './client.js';
// Only instantiate the real client outside of test environments. let _defaultTools: ReturnType<typeof buildTaskTools> | undefined;
// In test environments, tests import buildTaskTools directly and inject a mock.
const _client =
process.env['NODE_ENV'] === 'test'
? null
: new DynamoDBTasksClient();
export const tools = /** Build (once) the default tools wired to the real DynamoDB client. */
_client !== null export function getDefaultTools(): ReturnType<typeof buildTaskTools> {
? buildTaskTools(_client) if (!_defaultTools) {
: ([] as unknown as ReturnType<typeof buildTaskTools>); _defaultTools = buildTaskTools(new DynamoDBTasksClient());
}
return _defaultTools;
}

View file

@ -26,7 +26,15 @@ export function buildTaskTools(client: TasksClient) {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
const createTask = defineTool< const createTask = defineTool<
{ title: string; description?: string }, { 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', name: 'create_task',
description: description:
@ -75,7 +83,16 @@ export function buildTaskTools(client: TasksClient) {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
const listTasks = defineTool< const listTasks = defineTool<
{ includeCompleted?: boolean }, { 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', name: 'list_tasks',
description: description: