mirror of
https://github.com/Sea-Haven-Industries/sh-mcp.git
synced 2026-10-07 10:28:57 +00:00
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:
parent
c85789b75e
commit
2e16c306b0
33 changed files with 1051 additions and 237 deletions
|
|
@ -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.
|
||||
|
|
|
|||
164
packages/calendar/src/dev-client.ts
Normal file
164
packages/calendar/src/dev-client.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
|
|
|
|||
149
packages/gmail/src/dev-client.ts
Normal file
149
packages/gmail/src/dev-client.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@
|
|||
*/
|
||||
|
||||
export { GmailApiClient } from './client.js';
|
||||
export { InMemoryGmailClient } from './dev-client.js';
|
||||
export type {
|
||||
GmailClient,
|
||||
GoogleTokenProvider,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
57
packages/google-maps/src/dev-client.ts
Normal file
57
packages/google-maps/src/dev-client.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
124
packages/internal-data/src/dev-client.ts
Normal file
124
packages/internal-data/src/dev-client.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
40
packages/knowledge-base/src/dev-client.ts
Normal file
40
packages/knowledge-base/src/dev-client.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
84
packages/payments/src/dev-client.ts
Normal file
84
packages/payments/src/dev-client.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"rootDir": "src",
|
||||
"outDir": "./dist",
|
||||
"declarationDir": "./dist"
|
||||
},
|
||||
|
|
|
|||
63
packages/qbo/src/dev-client.ts
Normal file
63
packages/qbo/src/dev-client.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
25
packages/reminders/src/dev-client.ts
Normal file
25
packages/reminders/src/dev-client.ts
Normal 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}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
111
packages/tasks/src/dev-client.ts
Normal file
111
packages/tasks/src/dev-client.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue