mirror of
https://github.com/Sea-Haven-Industries/sh-mcp.git
synced 2026-10-07 16:18:58 +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;
|
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.
|
||||||
|
|
|
||||||
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,
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
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 { GmailApiClient } from './client.js';
|
||||||
|
export { InMemoryGmailClient } from './dev-client.js';
|
||||||
export type {
|
export type {
|
||||||
GmailClient,
|
GmailClient,
|
||||||
GoogleTokenProvider,
|
GoogleTokenProvider,
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
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 { 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>,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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,
|
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';
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
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,
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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
|
* // 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.
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
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 { 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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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 { 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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
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 { 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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue