diff --git a/CLAUDE.md b/CLAUDE.md index 7c9c5b18b..a30751db2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -625,6 +625,7 @@ Configure via `skillsConfig.auth` option: ❌ **Don't**: Name event properties `scope` when they don't refer to Scope class ❌ **Don't**: Put auth-related code in libs/sdk/src/auth (use libs/auth instead) ❌ **Don't**: Name test files with `.test.ts` extension (use `.spec.ts` instead) +❌ **Don't**: Use `ToolContext` — types are auto-inferred from the `@Tool` decorator; use plain `ToolContext` ✅ **Do**: Use clean, descriptive names for everything ✅ **Do**: Use `@frontmcp/utils` for file system and crypto operations diff --git a/apps/e2e/demo-e2e-authorities/src/apps/authorities/tools/admin-only.tool.ts b/apps/e2e/demo-e2e-authorities/src/apps/authorities/tools/admin-only.tool.ts index aa0f025f9..61d4c0f94 100644 --- a/apps/e2e/demo-e2e-authorities/src/apps/authorities/tools/admin-only.tool.ts +++ b/apps/e2e/demo-e2e-authorities/src/apps/authorities/tools/admin-only.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = { action: z.string().default('admin-action') }; type Input = z.infer>; @@ -12,7 +13,7 @@ type Input = z.infer>; roles: { any: ['admin', 'superadmin'] }, }, }) -export default class AdminOnlyTool extends ToolContext { +export default class AdminOnlyTool extends ToolContext { async execute(input: Input) { return { result: `admin action: ${input.action}` }; } diff --git a/apps/e2e/demo-e2e-authorities/src/apps/authorities/tools/combinator.tool.ts b/apps/e2e/demo-e2e-authorities/src/apps/authorities/tools/combinator.tool.ts index 87f0c268e..aeee711f7 100644 --- a/apps/e2e/demo-e2e-authorities/src/apps/authorities/tools/combinator.tool.ts +++ b/apps/e2e/demo-e2e-authorities/src/apps/authorities/tools/combinator.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = { action: z.string().default('combined') }; type Input = z.infer>; @@ -12,15 +13,12 @@ type Input = z.infer>; anyOf: [ { roles: { any: ['superadmin'] } }, { - allOf: [ - { roles: { any: ['admin', 'manager'] } }, - { not: { roles: { any: ['suspended'] } } }, - ], + allOf: [{ roles: { any: ['admin', 'manager'] } }, { not: { roles: { any: ['suspended'] } } }], }, ], }, }) -export default class CombinatorTool extends ToolContext { +export default class CombinatorTool extends ToolContext { async execute(input: Input) { return { result: input.action }; } diff --git a/apps/e2e/demo-e2e-authorities/src/apps/authorities/tools/editor-or-admin.tool.ts b/apps/e2e/demo-e2e-authorities/src/apps/authorities/tools/editor-or-admin.tool.ts index 5815efa6d..e37e002bb 100644 --- a/apps/e2e/demo-e2e-authorities/src/apps/authorities/tools/editor-or-admin.tool.ts +++ b/apps/e2e/demo-e2e-authorities/src/apps/authorities/tools/editor-or-admin.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = { content: z.string().default('draft') }; type Input = z.infer>; @@ -9,13 +10,10 @@ type Input = z.infer>; description: 'A tool accessible to editors OR admins (anyOf combinator)', inputSchema, authorities: { - anyOf: [ - { roles: { any: ['admin'] } }, - { permissions: { any: ['content:write', 'content:publish'] } }, - ], + anyOf: [{ roles: { any: ['admin'] } }, { permissions: { any: ['content:write', 'content:publish'] } }], }, }) -export default class EditorOrAdminTool extends ToolContext { +export default class EditorOrAdminTool extends ToolContext { async execute(input: Input) { return { published: input.content }; } diff --git a/apps/e2e/demo-e2e-authorities/src/apps/authorities/tools/permissions.tool.ts b/apps/e2e/demo-e2e-authorities/src/apps/authorities/tools/permissions.tool.ts index b6531d975..4266262d2 100644 --- a/apps/e2e/demo-e2e-authorities/src/apps/authorities/tools/permissions.tool.ts +++ b/apps/e2e/demo-e2e-authorities/src/apps/authorities/tools/permissions.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = { resource: z.string().default('users') }; type Input = z.infer>; @@ -12,7 +13,7 @@ type Input = z.infer>; permissions: { all: ['users:read', 'users:write'] }, }, }) -export default class PermissionsTool extends ToolContext { +export default class PermissionsTool extends ToolContext { async execute(input: Input) { return { access: 'granted', resource: input.resource }; } diff --git a/apps/e2e/demo-e2e-authorities/src/apps/authorities/tools/profile-admin.tool.ts b/apps/e2e/demo-e2e-authorities/src/apps/authorities/tools/profile-admin.tool.ts index a0b527488..95c8b8354 100644 --- a/apps/e2e/demo-e2e-authorities/src/apps/authorities/tools/profile-admin.tool.ts +++ b/apps/e2e/demo-e2e-authorities/src/apps/authorities/tools/profile-admin.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = { query: z.string().default('status') }; type Input = z.infer>; @@ -10,7 +11,7 @@ type Input = z.infer>; inputSchema, authorities: 'admin', }) -export default class ProfileAdminTool extends ToolContext { +export default class ProfileAdminTool extends ToolContext { async execute(input: Input) { return { admin: true, query: input.query }; } diff --git a/apps/e2e/demo-e2e-authorities/src/apps/authorities/tools/profile-multi.tool.ts b/apps/e2e/demo-e2e-authorities/src/apps/authorities/tools/profile-multi.tool.ts index 630d17412..0cf585aba 100644 --- a/apps/e2e/demo-e2e-authorities/src/apps/authorities/tools/profile-multi.tool.ts +++ b/apps/e2e/demo-e2e-authorities/src/apps/authorities/tools/profile-multi.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = { tenantId: z.string(), value: z.string().default('data'), @@ -13,7 +14,7 @@ type Input = z.infer>; inputSchema, authorities: ['authenticated', 'matchTenant'], }) -export default class ProfileMultiTool extends ToolContext { +export default class ProfileMultiTool extends ToolContext { async execute(input: Input) { return { tenant: input.tenantId, value: input.value }; } diff --git a/apps/e2e/demo-e2e-authorities/src/apps/authorities/tools/public.tool.ts b/apps/e2e/demo-e2e-authorities/src/apps/authorities/tools/public.tool.ts index fe53560b6..31a5c67f9 100644 --- a/apps/e2e/demo-e2e-authorities/src/apps/authorities/tools/public.tool.ts +++ b/apps/e2e/demo-e2e-authorities/src/apps/authorities/tools/public.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = { message: z.string().default('hello') }; type Input = z.infer>; @@ -9,7 +10,7 @@ type Input = z.infer>; description: 'A tool with no authorities — accessible to everyone', inputSchema, }) -export default class PublicTool extends ToolContext { +export default class PublicTool extends ToolContext { async execute(input: Input) { return { echo: input.message }; } diff --git a/apps/e2e/demo-e2e-authorities/src/apps/authorities/tools/tenant-scoped.tool.ts b/apps/e2e/demo-e2e-authorities/src/apps/authorities/tools/tenant-scoped.tool.ts index 3953ed315..0bc8deca0 100644 --- a/apps/e2e/demo-e2e-authorities/src/apps/authorities/tools/tenant-scoped.tool.ts +++ b/apps/e2e/demo-e2e-authorities/src/apps/authorities/tools/tenant-scoped.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = { tenantId: z.string(), data: z.string().default('payload'), @@ -13,13 +14,11 @@ type Input = z.infer>; inputSchema, authorities: { attributes: { - conditions: [ - { path: 'claims.tenantId', op: 'eq', value: { fromInput: 'tenantId' } }, - ], + conditions: [{ path: 'claims.tenantId', op: 'eq', value: { fromInput: 'tenantId' } }], }, }, }) -export default class TenantScopedTool extends ToolContext { +export default class TenantScopedTool extends ToolContext { async execute(input: Input) { return { tenant: input.tenantId, data: input.data }; } diff --git a/apps/e2e/demo-e2e-cache/src/apps/compute/tools/expensive-operation.tool.ts b/apps/e2e/demo-e2e-cache/src/apps/compute/tools/expensive-operation.tool.ts index 0335bf349..8ebe12602 100644 --- a/apps/e2e/demo-e2e-cache/src/apps/compute/tools/expensive-operation.tool.ts +++ b/apps/e2e/demo-e2e-cache/src/apps/compute/tools/expensive-operation.tool.ts @@ -1,5 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import { executionTracker } from '../data/execution-tracker'; const inputSchema = { @@ -26,7 +28,7 @@ type Output = z.infer; ttl: 30, // 30 second TTL }, }) -export default class ExpensiveOperationTool extends ToolContext { +export default class ExpensiveOperationTool extends ToolContext { async execute(input: Input): Promise { // Track actual execution const executionCount = executionTracker.increment('expensive-operation'); diff --git a/apps/e2e/demo-e2e-cache/src/apps/compute/tools/get-cache-stats.tool.ts b/apps/e2e/demo-e2e-cache/src/apps/compute/tools/get-cache-stats.tool.ts index dd88651a2..3b8b72b61 100644 --- a/apps/e2e/demo-e2e-cache/src/apps/compute/tools/get-cache-stats.tool.ts +++ b/apps/e2e/demo-e2e-cache/src/apps/compute/tools/get-cache-stats.tool.ts @@ -1,5 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import { executionTracker } from '../data/execution-tracker'; const inputSchema = {}; @@ -18,7 +20,7 @@ type Output = z.infer; inputSchema, outputSchema, }) -export default class GetCacheStatsTool extends ToolContext { +export default class GetCacheStatsTool extends ToolContext { async execute(_input: Input): Promise { const counts = executionTracker.getAll(); diff --git a/apps/e2e/demo-e2e-cache/src/apps/compute/tools/non-cached.tool.ts b/apps/e2e/demo-e2e-cache/src/apps/compute/tools/non-cached.tool.ts index 127cf9dc3..e079595a9 100644 --- a/apps/e2e/demo-e2e-cache/src/apps/compute/tools/non-cached.tool.ts +++ b/apps/e2e/demo-e2e-cache/src/apps/compute/tools/non-cached.tool.ts @@ -1,5 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import { executionTracker } from '../data/execution-tracker'; const inputSchema = { @@ -23,7 +25,7 @@ type Output = z.infer; outputSchema, // No cache configuration - every call executes }) -export default class NonCachedTool extends ToolContext { +export default class NonCachedTool extends ToolContext { async execute(input: Input): Promise { // Track actual execution const executionCount = executionTracker.increment('non-cached'); diff --git a/apps/e2e/demo-e2e-cache/src/apps/compute/tools/reset-stats.tool.ts b/apps/e2e/demo-e2e-cache/src/apps/compute/tools/reset-stats.tool.ts index 61c0562d7..09e374aa9 100644 --- a/apps/e2e/demo-e2e-cache/src/apps/compute/tools/reset-stats.tool.ts +++ b/apps/e2e/demo-e2e-cache/src/apps/compute/tools/reset-stats.tool.ts @@ -1,5 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import { executionTracker } from '../data/execution-tracker'; const inputSchema = {}; @@ -18,7 +20,7 @@ type Output = z.infer; inputSchema, outputSchema, }) -export default class ResetStatsTool extends ToolContext { +export default class ResetStatsTool extends ToolContext { async execute(_input: Input): Promise { executionTracker.reset(); diff --git a/apps/e2e/demo-e2e-channels/src/apps/channels/channels/messaging-service.channel.ts b/apps/e2e/demo-e2e-channels/src/apps/channels/channels/messaging-service.channel.ts index ff29f7fe8..d7a59fdf8 100644 --- a/apps/e2e/demo-e2e-channels/src/apps/channels/channels/messaging-service.channel.ts +++ b/apps/e2e/demo-e2e-channels/src/apps/channels/channels/messaging-service.channel.ts @@ -11,10 +11,10 @@ * 2. Recipient replies → onEvent() triggered → notification to Claude */ -import { Channel, ChannelContext, Tool, ToolContext } from '@frontmcp/sdk'; -import type { ChannelNotification } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Channel, ChannelContext, Tool, ToolContext, type ChannelNotification } from '@frontmcp/sdk'; + // ─── Simulated Service State ──────────────────────────────────── /** Log of all messages sent through the service (for test verification) */ @@ -52,7 +52,7 @@ const sendInputSchema = { openWorldHint: true, }, }) -export class SendMessageTool extends ToolContext { +export class SendMessageTool extends ToolContext { async execute(input: { to: string; text: string }) { sentMessages.push({ to: input.to, text: input.text, timestamp: Date.now() }); return { diff --git a/apps/e2e/demo-e2e-channels/src/apps/channels/tools/emit-app-event.tool.ts b/apps/e2e/demo-e2e-channels/src/apps/channels/tools/emit-app-event.tool.ts index ac372e500..55c0992da 100644 --- a/apps/e2e/demo-e2e-channels/src/apps/channels/tools/emit-app-event.tool.ts +++ b/apps/e2e/demo-e2e-channels/src/apps/channels/tools/emit-app-event.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = { event: z.string().describe('Event name to emit (e.g., "app:error")'), payload: z.record(z.string(), z.unknown()).describe('Event payload'), @@ -11,7 +12,7 @@ const inputSchema = { description: 'Emit an app event to the ChannelEventBus for testing channel sources', inputSchema, }) -export default class EmitAppEventTool extends ToolContext { +export default class EmitAppEventTool extends ToolContext { async execute(input: { event: string; payload: Record }) { const scope = this.scope as unknown as { channelEventBus?: { emit: (event: string, payload: unknown) => void } }; const eventBus = scope.channelEventBus; diff --git a/apps/e2e/demo-e2e-channels/src/apps/channels/tools/file-watcher-tools.tool.ts b/apps/e2e/demo-e2e-channels/src/apps/channels/tools/file-watcher-tools.tool.ts index e068e7208..6e02c59f6 100644 --- a/apps/e2e/demo-e2e-channels/src/apps/channels/tools/file-watcher-tools.tool.ts +++ b/apps/e2e/demo-e2e-channels/src/apps/channels/tools/file-watcher-tools.tool.ts @@ -1,5 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import { simulateFileEvent } from '../channels/file-watcher.channel'; const inputSchema = { @@ -13,7 +15,7 @@ const inputSchema = { description: 'Simulate a file system event for the file-watcher channel', inputSchema, }) -export default class SimulateFileEventTool extends ToolContext { +export default class SimulateFileEventTool extends ToolContext { async execute(input: { file: string; event: string; content?: string }) { simulateFileEvent(input.file, input.event, input.content); return { simulated: true, file: input.file, event: input.event }; diff --git a/apps/e2e/demo-e2e-channels/src/apps/channels/tools/replay-tools.tool.ts b/apps/e2e/demo-e2e-channels/src/apps/channels/tools/replay-tools.tool.ts index 18b869380..ac093def9 100644 --- a/apps/e2e/demo-e2e-channels/src/apps/channels/tools/replay-tools.tool.ts +++ b/apps/e2e/demo-e2e-channels/src/apps/channels/tools/replay-tools.tool.ts @@ -1,6 +1,6 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; -import type { ChannelRegistry } from '@frontmcp/sdk'; + +import { Tool, ToolContext, type ChannelRegistry } from '@frontmcp/sdk'; const getBufferSchema = { channelName: z.string().describe('Channel name to get buffer for'), @@ -11,7 +11,7 @@ const getBufferSchema = { description: 'Get the replay buffer contents for a channel', inputSchema: getBufferSchema, }) -export class GetReplayBufferTool extends ToolContext { +export class GetReplayBufferTool extends ToolContext { async execute(input: { channelName: string }) { const scope = this.scope as unknown as { channels?: ChannelRegistry }; const registry = scope.channels; @@ -38,7 +38,7 @@ const clearBufferSchema = { description: 'Clear the replay buffer for a channel', inputSchema: clearBufferSchema, }) -export class ClearReplayBufferTool extends ToolContext { +export class ClearReplayBufferTool extends ToolContext { async execute(input: { channelName: string }) { const scope = this.scope as unknown as { channels?: ChannelRegistry }; const registry = scope.channels; diff --git a/apps/e2e/demo-e2e-channels/src/apps/channels/tools/send-channel-notification.tool.ts b/apps/e2e/demo-e2e-channels/src/apps/channels/tools/send-channel-notification.tool.ts index f2ba512cf..5d25e206c 100644 --- a/apps/e2e/demo-e2e-channels/src/apps/channels/tools/send-channel-notification.tool.ts +++ b/apps/e2e/demo-e2e-channels/src/apps/channels/tools/send-channel-notification.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = { channelName: z.string().describe('Channel name to send notification through'), content: z.string().describe('Notification content'), @@ -12,7 +13,7 @@ const inputSchema = { description: 'Manually send a notification through the channel system', inputSchema, }) -export default class SendChannelNotificationTool extends ToolContext { +export default class SendChannelNotificationTool extends ToolContext { async execute(input: { channelName: string; content: string; meta?: Record }) { const scope = this.scope as unknown as { channelNotifications?: { send: (name: string, content: string, meta?: Record) => void }; diff --git a/apps/e2e/demo-e2e-channels/src/apps/channels/tools/session-tools.tool.ts b/apps/e2e/demo-e2e-channels/src/apps/channels/tools/session-tools.tool.ts index f57809a71..d4a5238e1 100644 --- a/apps/e2e/demo-e2e-channels/src/apps/channels/tools/session-tools.tool.ts +++ b/apps/e2e/demo-e2e-channels/src/apps/channels/tools/session-tools.tool.ts @@ -5,8 +5,10 @@ * sessions with the NotificationService and managing channel subscriptions. */ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + // Use structural types instead of deep internal SDK imports interface NotificationServiceLike { registerServer(sessionId: string, server: unknown): void; @@ -171,7 +173,7 @@ const manageSubSchema = { description: 'Subscribe or unsubscribe a session from a specific channel', inputSchema: manageSubSchema, }) -export class ManageChannelSubscriptionTool extends ToolContext { +export class ManageChannelSubscriptionTool extends ToolContext { async execute(input: { sessionId: string; channelName: string; action: 'subscribe' | 'unsubscribe' }) { const notifications = this.scope.notifications as NotificationServiceLike; diff --git a/apps/e2e/demo-e2e-channels/src/apps/channels/tools/simulate-incoming.tool.ts b/apps/e2e/demo-e2e-channels/src/apps/channels/tools/simulate-incoming.tool.ts index 42a1f409e..35993d661 100644 --- a/apps/e2e/demo-e2e-channels/src/apps/channels/tools/simulate-incoming.tool.ts +++ b/apps/e2e/demo-e2e-channels/src/apps/channels/tools/simulate-incoming.tool.ts @@ -1,6 +1,8 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; -import { simulateIncomingMessage, sentMessages } from '../channels/messaging-service.channel'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + +import { sentMessages, simulateIncomingMessage } from '../channels/messaging-service.channel'; const inputSchema = { from: z.string().describe('Sender name/ID'), @@ -13,7 +15,7 @@ const inputSchema = { description: 'Simulate an incoming message from the messaging service (for testing)', inputSchema, }) -export default class SimulateIncomingTool extends ToolContext { +export default class SimulateIncomingTool extends ToolContext { async execute(input: { from: string; text: string; chatId: string }) { simulateIncomingMessage(input.from, input.text, input.chatId); return { simulated: true, from: input.from }; diff --git a/apps/e2e/demo-e2e-cimd/src/apps/notes/tools/create-note.tool.ts b/apps/e2e/demo-e2e-cimd/src/apps/notes/tools/create-note.tool.ts index 9a2d94c6f..2d2d8b52c 100644 --- a/apps/e2e/demo-e2e-cimd/src/apps/notes/tools/create-note.tool.ts +++ b/apps/e2e/demo-e2e-cimd/src/apps/notes/tools/create-note.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = { title: z.string().describe('Note title'), content: z.string().describe('Note content'), @@ -22,7 +23,7 @@ type Output = z.infer; inputSchema, outputSchema, }) -export default class CreateNoteTool extends ToolContext { +export default class CreateNoteTool extends ToolContext { async execute(input: Input): Promise { return { id: `note-${Date.now()}`, diff --git a/apps/e2e/demo-e2e-cimd/src/apps/notes/tools/list-notes.tool.ts b/apps/e2e/demo-e2e-cimd/src/apps/notes/tools/list-notes.tool.ts index e3092fbcb..9162c0cb4 100644 --- a/apps/e2e/demo-e2e-cimd/src/apps/notes/tools/list-notes.tool.ts +++ b/apps/e2e/demo-e2e-cimd/src/apps/notes/tools/list-notes.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = {}; const outputSchema = z.object({ @@ -22,7 +23,7 @@ type Output = z.infer; inputSchema, outputSchema, }) -export default class ListNotesTool extends ToolContext { +export default class ListNotesTool extends ToolContext { async execute(_input: Input): Promise { // Return mock notes for testing return { diff --git a/apps/e2e/demo-e2e-codecall/src/apps/crm/tools/activities-list.tool.ts b/apps/e2e/demo-e2e-codecall/src/apps/crm/tools/activities-list.tool.ts index 58f566bfc..1b9eb8adc 100644 --- a/apps/e2e/demo-e2e-codecall/src/apps/crm/tools/activities-list.tool.ts +++ b/apps/e2e/demo-e2e-codecall/src/apps/crm/tools/activities-list.tool.ts @@ -1,5 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import { crmStore } from '../data/crm.store'; const inputSchema = { @@ -25,7 +27,7 @@ const outputSchema = z.object({ inputSchema, outputSchema, }) -export default class ActivitiesListTool extends ToolContext { +export default class ActivitiesListTool extends ToolContext { async execute(input: z.infer>): Promise> { const activities = crmStore.listActivities(input.userId); return { activities, count: activities.length }; diff --git a/apps/e2e/demo-e2e-codecall/src/apps/crm/tools/activities-log.tool.ts b/apps/e2e/demo-e2e-codecall/src/apps/crm/tools/activities-log.tool.ts index 36881fcaa..0503b00bd 100644 --- a/apps/e2e/demo-e2e-codecall/src/apps/crm/tools/activities-log.tool.ts +++ b/apps/e2e/demo-e2e-codecall/src/apps/crm/tools/activities-log.tool.ts @@ -1,5 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import { crmStore } from '../data/crm.store'; const inputSchema = { @@ -24,7 +26,7 @@ const outputSchema = z.object({ inputSchema, outputSchema, }) -export default class ActivitiesLogTool extends ToolContext { +export default class ActivitiesLogTool extends ToolContext { async execute(input: z.infer>): Promise> { const activity = crmStore.logActivity(input); return { activity }; diff --git a/apps/e2e/demo-e2e-codecall/src/apps/crm/tools/activities-stats.tool.ts b/apps/e2e/demo-e2e-codecall/src/apps/crm/tools/activities-stats.tool.ts index 812ea6f31..69341b2a2 100644 --- a/apps/e2e/demo-e2e-codecall/src/apps/crm/tools/activities-stats.tool.ts +++ b/apps/e2e/demo-e2e-codecall/src/apps/crm/tools/activities-stats.tool.ts @@ -1,5 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import { crmStore } from '../data/crm.store'; const inputSchema = {}; @@ -16,7 +18,7 @@ const outputSchema = z.object({ inputSchema, outputSchema, }) -export default class ActivitiesStatsTool extends ToolContext { +export default class ActivitiesStatsTool extends ToolContext { async execute(_input: z.infer>): Promise> { return crmStore.getActivityStats(); } diff --git a/apps/e2e/demo-e2e-codecall/src/apps/crm/tools/crm-reset.tool.ts b/apps/e2e/demo-e2e-codecall/src/apps/crm/tools/crm-reset.tool.ts index 67d231c9e..62bb270b5 100644 --- a/apps/e2e/demo-e2e-codecall/src/apps/crm/tools/crm-reset.tool.ts +++ b/apps/e2e/demo-e2e-codecall/src/apps/crm/tools/crm-reset.tool.ts @@ -1,5 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import { crmStore } from '../data/crm.store'; const inputSchema = {}; @@ -11,7 +13,7 @@ const outputSchema = z.object({ success: z.boolean() }); inputSchema, outputSchema, }) -export default class CrmResetTool extends ToolContext { +export default class CrmResetTool extends ToolContext { async execute(_input: z.infer>): Promise> { crmStore.reset(); return { success: true }; diff --git a/apps/e2e/demo-e2e-codecall/src/apps/crm/tools/users-create.tool.ts b/apps/e2e/demo-e2e-codecall/src/apps/crm/tools/users-create.tool.ts index 6eba3b780..67a2a8fa0 100644 --- a/apps/e2e/demo-e2e-codecall/src/apps/crm/tools/users-create.tool.ts +++ b/apps/e2e/demo-e2e-codecall/src/apps/crm/tools/users-create.tool.ts @@ -1,5 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import { crmStore } from '../data/crm.store'; const inputSchema = { @@ -26,7 +28,7 @@ const outputSchema = z.object({ inputSchema, outputSchema, }) -export default class UsersCreateTool extends ToolContext { +export default class UsersCreateTool extends ToolContext { async execute(input: z.infer>): Promise> { const user = crmStore.createUser(input); return { user }; diff --git a/apps/e2e/demo-e2e-codecall/src/apps/crm/tools/users-delete.tool.ts b/apps/e2e/demo-e2e-codecall/src/apps/crm/tools/users-delete.tool.ts index 68a8d3fb2..29a3ab7f9 100644 --- a/apps/e2e/demo-e2e-codecall/src/apps/crm/tools/users-delete.tool.ts +++ b/apps/e2e/demo-e2e-codecall/src/apps/crm/tools/users-delete.tool.ts @@ -1,5 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import { crmStore } from '../data/crm.store'; const inputSchema = { @@ -17,7 +19,7 @@ const outputSchema = z.object({ inputSchema, outputSchema, }) -export default class UsersDeleteTool extends ToolContext { +export default class UsersDeleteTool extends ToolContext { async execute(input: z.infer>): Promise> { const deleted = crmStore.deleteUser(input.id); return { diff --git a/apps/e2e/demo-e2e-codecall/src/apps/crm/tools/users-get.tool.ts b/apps/e2e/demo-e2e-codecall/src/apps/crm/tools/users-get.tool.ts index ffe3e54a5..c08369f7d 100644 --- a/apps/e2e/demo-e2e-codecall/src/apps/crm/tools/users-get.tool.ts +++ b/apps/e2e/demo-e2e-codecall/src/apps/crm/tools/users-get.tool.ts @@ -1,5 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import { crmStore } from '../data/crm.store'; const inputSchema = { @@ -25,7 +27,7 @@ const outputSchema = z.object({ inputSchema, outputSchema, }) -export default class UsersGetTool extends ToolContext { +export default class UsersGetTool extends ToolContext { async execute(input: z.infer>): Promise> { const user = crmStore.getUser(input.id); return { user: user || null }; diff --git a/apps/e2e/demo-e2e-codecall/src/apps/crm/tools/users-list.tool.ts b/apps/e2e/demo-e2e-codecall/src/apps/crm/tools/users-list.tool.ts index c57a001e9..d9b35c84c 100644 --- a/apps/e2e/demo-e2e-codecall/src/apps/crm/tools/users-list.tool.ts +++ b/apps/e2e/demo-e2e-codecall/src/apps/crm/tools/users-list.tool.ts @@ -1,5 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import { crmStore } from '../data/crm.store'; const inputSchema = {}; @@ -24,7 +26,7 @@ const outputSchema = z.object({ inputSchema, outputSchema, }) -export default class UsersListTool extends ToolContext { +export default class UsersListTool extends ToolContext { async execute(_input: z.infer>): Promise> { const users = crmStore.listUsers(); return { users, count: users.length }; diff --git a/apps/e2e/demo-e2e-codecall/src/apps/crm/tools/users-update.tool.ts b/apps/e2e/demo-e2e-codecall/src/apps/crm/tools/users-update.tool.ts index 73fbe25e3..9e3c819eb 100644 --- a/apps/e2e/demo-e2e-codecall/src/apps/crm/tools/users-update.tool.ts +++ b/apps/e2e/demo-e2e-codecall/src/apps/crm/tools/users-update.tool.ts @@ -1,5 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import { crmStore } from '../data/crm.store'; const inputSchema = { @@ -30,7 +32,7 @@ const outputSchema = z.object({ inputSchema, outputSchema, }) -export default class UsersUpdateTool extends ToolContext { +export default class UsersUpdateTool extends ToolContext { async execute(input: z.infer>): Promise> { const { id, ...updates } = input; const user = crmStore.updateUser(id, updates); diff --git a/apps/e2e/demo-e2e-config/src/apps/config/tools/check-config.tool.ts b/apps/e2e/demo-e2e-config/src/apps/config/tools/check-config.tool.ts index 0f39cfdee..209c63863 100644 --- a/apps/e2e/demo-e2e-config/src/apps/config/tools/check-config.tool.ts +++ b/apps/e2e/demo-e2e-config/src/apps/config/tools/check-config.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = { key: z.string().describe('Environment variable key to check'), }; @@ -24,7 +25,7 @@ type Output = z.infer; inputSchema, outputSchema, }) -export default class CheckConfigTool extends ToolContext { +export default class CheckConfigTool extends ToolContext { async execute(input: Input): Promise { const exists = this.config.has(input.key); const numberValue = this.config.getNumber(input.key); diff --git a/apps/e2e/demo-e2e-config/src/apps/config/tools/get-all-config.tool.ts b/apps/e2e/demo-e2e-config/src/apps/config/tools/get-all-config.tool.ts index 34411bba7..d55ae612a 100644 --- a/apps/e2e/demo-e2e-config/src/apps/config/tools/get-all-config.tool.ts +++ b/apps/e2e/demo-e2e-config/src/apps/config/tools/get-all-config.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = {}; const outputSchema = z.object({ @@ -18,7 +19,7 @@ type Output = z.infer; inputSchema, outputSchema, }) -export default class GetAllConfigTool extends ToolContext { +export default class GetAllConfigTool extends ToolContext { async execute(_input: Input): Promise { const all = this.config.getAll(); const keys = Object.keys(all); diff --git a/apps/e2e/demo-e2e-config/src/apps/config/tools/get-config.tool.ts b/apps/e2e/demo-e2e-config/src/apps/config/tools/get-config.tool.ts index b0074c420..cc5a05f5c 100644 --- a/apps/e2e/demo-e2e-config/src/apps/config/tools/get-config.tool.ts +++ b/apps/e2e/demo-e2e-config/src/apps/config/tools/get-config.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = { key: z.string().describe('Environment variable key to retrieve'), defaultValue: z.string().optional().describe('Default value if key is not found'), @@ -22,7 +23,7 @@ type Output = z.infer; inputSchema, outputSchema, }) -export default class GetConfigTool extends ToolContext { +export default class GetConfigTool extends ToolContext { async execute(input: Input): Promise { const found = this.config.has(input.key); // Use default value only if provided, otherwise get raw value diff --git a/apps/e2e/demo-e2e-config/src/apps/config/tools/get-required-config.tool.ts b/apps/e2e/demo-e2e-config/src/apps/config/tools/get-required-config.tool.ts index eee4b94cd..b70b081f0 100644 --- a/apps/e2e/demo-e2e-config/src/apps/config/tools/get-required-config.tool.ts +++ b/apps/e2e/demo-e2e-config/src/apps/config/tools/get-required-config.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = { key: z.string().describe('Environment variable key to retrieve (must exist)'), }; @@ -21,7 +22,7 @@ type Output = z.infer; inputSchema, outputSchema, }) -export default class GetRequiredConfigTool extends ToolContext { +export default class GetRequiredConfigTool extends ToolContext { async execute(input: Input): Promise { try { const value = this.config.getRequired(input.key); diff --git a/apps/e2e/demo-e2e-config/src/apps/config/tools/test-config-fallback.tool.ts b/apps/e2e/demo-e2e-config/src/apps/config/tools/test-config-fallback.tool.ts index 2329986bf..24240d14a 100644 --- a/apps/e2e/demo-e2e-config/src/apps/config/tools/test-config-fallback.tool.ts +++ b/apps/e2e/demo-e2e-config/src/apps/config/tools/test-config-fallback.tool.ts @@ -1,11 +1,13 @@ import { z } from 'zod'; -import { Tool, ToolContext } from '@frontmcp/sdk'; + import { - generateFallbacks, generateEnvFallbacks, + generateFallbacks, normalizeNameForEnv, normalizePathSegment, - ConfigEntityType, + Tool, + ToolContext, + type ConfigEntityType, } from '@frontmcp/sdk'; const inputSchema = { @@ -43,7 +45,7 @@ interface FallbackResult { description: 'Test config fallback resolution with entity context', inputSchema, }) -export default class TestConfigFallbackTool extends ToolContext { +export default class TestConfigFallbackTool extends ToolContext { async execute(input: Input): Promise { const { key, entityType, entityName, customFallbacks, disableFallbacks } = input; diff --git a/apps/e2e/demo-e2e-direct/src/apps/notes/tools/create-note.tool.ts b/apps/e2e/demo-e2e-direct/src/apps/notes/tools/create-note.tool.ts index 3a95e69ca..c0eaa9678 100644 --- a/apps/e2e/demo-e2e-direct/src/apps/notes/tools/create-note.tool.ts +++ b/apps/e2e/demo-e2e-direct/src/apps/notes/tools/create-note.tool.ts @@ -1,6 +1,8 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; -import { randomUUID } from 'crypto'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; +import { randomUUID } from '@frontmcp/utils'; + import { notesStore } from '../data/notes.store'; const inputSchema = { @@ -24,7 +26,7 @@ type CreateNoteOutput = z.infer; inputSchema, outputSchema, }) -export default class CreateNoteTool extends ToolContext { +export default class CreateNoteTool extends ToolContext { async execute(input: CreateNoteInput): Promise { const note = { id: `note-${randomUUID()}`, diff --git a/apps/e2e/demo-e2e-direct/src/apps/notes/tools/get-auth-info.tool.ts b/apps/e2e/demo-e2e-direct/src/apps/notes/tools/get-auth-info.tool.ts index 1d59df88a..aa00a516a 100644 --- a/apps/e2e/demo-e2e-direct/src/apps/notes/tools/get-auth-info.tool.ts +++ b/apps/e2e/demo-e2e-direct/src/apps/notes/tools/get-auth-info.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext, FRONTMCP_CONTEXT } from '@frontmcp/sdk'; import { z } from 'zod'; +import { FRONTMCP_CONTEXT, Tool, ToolContext } from '@frontmcp/sdk'; + /** * Auth info structure for DirectClient connections. * User info is stored directly in authInfo when connecting via DirectClient. @@ -40,7 +41,7 @@ type GetAuthInfoOutput = z.infer; inputSchema, outputSchema, }) -export default class GetAuthInfoTool extends ToolContext { +export default class GetAuthInfoTool extends ToolContext { async execute(_input: GetAuthInfoInput): Promise { const ctx = this.tryGet(FRONTMCP_CONTEXT); diff --git a/apps/e2e/demo-e2e-direct/src/apps/notes/tools/list-notes.tool.ts b/apps/e2e/demo-e2e-direct/src/apps/notes/tools/list-notes.tool.ts index 225d3fdaa..19ee8668f 100644 --- a/apps/e2e/demo-e2e-direct/src/apps/notes/tools/list-notes.tool.ts +++ b/apps/e2e/demo-e2e-direct/src/apps/notes/tools/list-notes.tool.ts @@ -1,5 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import { notesStore } from '../data/notes.store'; const inputSchema = {}; @@ -25,7 +27,7 @@ type ListNotesOutput = z.infer; inputSchema, outputSchema, }) -export default class ListNotesTool extends ToolContext { +export default class ListNotesTool extends ToolContext { async execute(_input: ListNotesInput): Promise { const notes = notesStore.getAll(); return { diff --git a/apps/e2e/demo-e2e-distributed/e2e/session-scaling.e2e.spec.ts b/apps/e2e/demo-e2e-distributed/e2e/session-scaling.e2e.spec.ts new file mode 100644 index 000000000..17c9cbf69 --- /dev/null +++ b/apps/e2e/demo-e2e-distributed/e2e/session-scaling.e2e.spec.ts @@ -0,0 +1,126 @@ +/** + * E2E Tests for Distributed Session Scaling + * + * Tests that sessions created on one node can be accessed from another node + * via Redis session persistence and transport recreation. + * + * Requires Docker for testcontainers Redis. + */ + +import { McpTestClient } from '@frontmcp/testing'; + +import { + shouldSkipDistributedTests, + startRedisContainer, + type RedisContainerInfo, +} from '../../../../libs/testing/src/containers/redis-container'; +import { DistributedTestCluster, type ClusterNode } from '../../../../libs/testing/src/server/distributed-test-cluster'; + +const SKIP = shouldSkipDistributedTests(); + +describe('Distributed Session Scaling', () => { + let redis: RedisContainerInfo; + let cluster: DistributedTestCluster; + let nodes: ClusterNode[]; + + beforeAll(async () => { + if (SKIP) return; + + // Start Redis container + redis = await startRedisContainer(); + + // Start 2-node cluster + cluster = new DistributedTestCluster({ + redisUrl: redis.url, + serverEntry: 'apps/e2e/demo-e2e-distributed/src/main.ts', + project: 'demo-e2e-distributed', + }); + nodes = await cluster.start(2); + }, 90_000); + + afterAll(async () => { + if (SKIP) return; + await cluster?.teardown(); + await redis?.stop(); + }); + + const skipIf = (condition: boolean) => (condition ? it.skip : it); + + skipIf(SKIP)('both nodes should be running and accessible', async () => { + expect(nodes).toHaveLength(2); + expect(nodes[0].info.baseUrl).toBeDefined(); + expect(nodes[1].info.baseUrl).toBeDefined(); + expect(nodes[0].machineId).toBe('node-0'); + expect(nodes[1].machineId).toBe('node-1'); + }); + + skipIf(SKIP)('should initialize session on node 0 and get tool response', async () => { + const client = await McpTestClient.create({ + baseUrl: nodes[0].info.baseUrl, + }).buildAndConnect(); + + try { + const result = await client.tools.call('echo', { message: 'hello' }); + expect(result).toBeSuccessful(); + // Tool returns { echo: "[node-0] hello" } serialized as JSON text + expect(result).toHaveTextContent('node-0'); + expect(result).toHaveTextContent('hello'); + } finally { + await client.disconnect(); + } + }); + + skipIf(SKIP)('node-info should report correct machine ID per node', async () => { + const client0 = await McpTestClient.create({ + baseUrl: nodes[0].info.baseUrl, + }).buildAndConnect(); + + const client1 = await McpTestClient.create({ + baseUrl: nodes[1].info.baseUrl, + }).buildAndConnect(); + + try { + const result0 = await client0.tools.call('node-info', {}); + const result1 = await client1.tools.call('node-info', {}); + + expect(result0).toBeSuccessful(); + expect(result1).toBeSuccessful(); + + // Verify each node reports its own machine ID + expect(result0).toHaveTextContent('node-0'); + expect(result1).toHaveTextContent('node-1'); + + // Verify deployment mode is reported + expect(result0).toHaveTextContent('standalone'); + expect(result1).toHaveTextContent('standalone'); + } finally { + await client0.disconnect(); + await client1.disconnect(); + } + }); + + skipIf(SKIP)('both nodes should list the same tools', async () => { + const client0 = await McpTestClient.create({ + baseUrl: nodes[0].info.baseUrl, + }).buildAndConnect(); + + const client1 = await McpTestClient.create({ + baseUrl: nodes[1].info.baseUrl, + }).buildAndConnect(); + + try { + const tools0 = await client0.tools.list(); + const tools1 = await client1.tools.list(); + + const toolNames0 = tools0.map((t) => t.name).sort(); + const toolNames1 = tools1.map((t) => t.name).sort(); + + expect(toolNames0).toEqual(toolNames1); + expect(toolNames0).toContain('echo'); + expect(toolNames0).toContain('node-info'); + } finally { + await client0.disconnect(); + await client1.disconnect(); + } + }); +}); diff --git a/apps/e2e/demo-e2e-distributed/jest.e2e.config.ts b/apps/e2e/demo-e2e-distributed/jest.e2e.config.ts new file mode 100644 index 000000000..52f63855e --- /dev/null +++ b/apps/e2e/demo-e2e-distributed/jest.e2e.config.ts @@ -0,0 +1,46 @@ +import { createRequire } from 'module'; + +import type { Config } from '@jest/types'; + +const require = createRequire(import.meta.url); +const e2eCoveragePreset = require('../../../jest.e2e.coverage.preset.js'); + +const config: Config.InitialOptions = { + displayName: 'demo-e2e-distributed', + preset: '../../../jest.preset.js', + testEnvironment: 'node', + testMatch: ['/e2e/**/*.e2e.spec.ts'], + // Longer timeout for container startup + multi-server orchestration + testTimeout: 120_000, + maxWorkers: 1, + setupFilesAfterEnv: ['/../../../libs/testing/src/setup.ts'], + transformIgnorePatterns: ['node_modules/(?!(jose)/)'], + transform: { + '^.+\\.[tj]s$': [ + '@swc/jest', + { + jsc: { + parser: { + syntax: 'typescript', + decorators: true, + }, + transform: { + decoratorMetadata: true, + }, + target: 'es2022', + }, + }, + ], + }, + moduleNameMapper: { + '^@frontmcp/testing$': '/../../../libs/testing/src/index.ts', + '^@frontmcp/sdk$': '/../../../libs/sdk/src/index.ts', + '^@frontmcp/adapters$': '/../../../libs/adapters/src/index.ts', + '^@frontmcp/auth$': '/../../../libs/auth/src/index.ts', + '^@frontmcp/utils$': '/../../../libs/utils/src/index.ts', + }, + coverageDirectory: '../../../coverage/e2e/demo-e2e-distributed', + ...e2eCoveragePreset, +}; + +export default config; diff --git a/apps/e2e/demo-e2e-distributed/project.json b/apps/e2e/demo-e2e-distributed/project.json new file mode 100644 index 000000000..c50eeecb6 --- /dev/null +++ b/apps/e2e/demo-e2e-distributed/project.json @@ -0,0 +1,54 @@ +{ + "name": "demo-e2e-distributed", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "apps/e2e/demo-e2e-distributed/src", + "projectType": "application", + "tags": ["scope:demo", "type:e2e", "feature:distributed"], + "targets": { + "build": { + "executor": "@nx/webpack:webpack", + "outputs": ["{options.outputPath}"], + "defaultConfiguration": "development", + "options": { + "target": "node", + "compiler": "tsc", + "outputPath": "dist/apps/e2e/demo-e2e-distributed", + "main": "apps/e2e/demo-e2e-distributed/src/main.ts", + "tsConfig": "apps/e2e/demo-e2e-distributed/tsconfig.app.json", + "webpackConfig": "apps/e2e/demo-e2e-distributed/webpack.config.js", + "generatePackageJson": true + }, + "configurations": { + "development": {}, + "production": { + "optimization": true + } + } + }, + "serve": { + "executor": "nx:run-commands", + "dependsOn": ["build"], + "options": { + "command": "node dist/apps/e2e/demo-e2e-distributed/main.js", + "cwd": "{workspaceRoot}" + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/apps/e2e/demo-e2e-distributed"], + "options": { + "jestConfig": "apps/e2e/demo-e2e-distributed/jest.e2e.config.ts", + "passWithNoTests": true + } + }, + "test:e2e": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/apps/e2e/demo-e2e-distributed-e2e"], + "options": { + "jestConfig": "apps/e2e/demo-e2e-distributed/jest.e2e.config.ts", + "runInBand": true, + "passWithNoTests": true + } + } + } +} diff --git a/apps/e2e/demo-e2e-distributed/src/apps/distributed-app/index.ts b/apps/e2e/demo-e2e-distributed/src/apps/distributed-app/index.ts new file mode 100644 index 000000000..cece16135 --- /dev/null +++ b/apps/e2e/demo-e2e-distributed/src/apps/distributed-app/index.ts @@ -0,0 +1,10 @@ +import { App } from '@frontmcp/sdk'; + +import EchoTool from './tools/echo.tool'; +import NodeInfoTool from './tools/node-info.tool'; + +@App({ + name: 'distributed', + tools: [EchoTool, NodeInfoTool], +}) +export class DistributedApp {} diff --git a/apps/e2e/demo-e2e-distributed/src/apps/distributed-app/tools/echo.tool.ts b/apps/e2e/demo-e2e-distributed/src/apps/distributed-app/tools/echo.tool.ts new file mode 100644 index 000000000..9cbeaece0 --- /dev/null +++ b/apps/e2e/demo-e2e-distributed/src/apps/distributed-app/tools/echo.tool.ts @@ -0,0 +1,21 @@ +import { z } from 'zod'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + +const inputSchema = { + message: z.string(), +}; + +type Input = z.infer>; + +@Tool({ + name: 'echo', + description: 'Echoes the input back with node metadata', + inputSchema, +}) +export default class EchoTool extends ToolContext { + async execute({ message }: Input) { + const machineId = process.env['MACHINE_ID'] ?? 'unknown'; + return { echo: `[${machineId}] ${message}` }; + } +} diff --git a/apps/e2e/demo-e2e-distributed/src/apps/distributed-app/tools/node-info.tool.ts b/apps/e2e/demo-e2e-distributed/src/apps/distributed-app/tools/node-info.tool.ts new file mode 100644 index 000000000..5550480d7 --- /dev/null +++ b/apps/e2e/demo-e2e-distributed/src/apps/distributed-app/tools/node-info.tool.ts @@ -0,0 +1,24 @@ +import { Tool, ToolContext } from '@frontmcp/sdk'; +import { getMachineId } from '@frontmcp/utils'; + +const inputSchema = {}; + +@Tool({ + name: 'node-info', + description: 'Returns information about the current node', + inputSchema, +}) +export default class NodeInfoTool extends ToolContext { + async execute() { + const machineId = getMachineId(); + const pid = process.pid; + const port = process.env['PORT'] ?? 'unknown'; + + return { + machineId, + pid, + port, + deployment: process.env['FRONTMCP_DEPLOYMENT_MODE'] ?? 'standalone', + }; + } +} diff --git a/apps/e2e/demo-e2e-distributed/src/main.ts b/apps/e2e/demo-e2e-distributed/src/main.ts new file mode 100644 index 000000000..992b6469f --- /dev/null +++ b/apps/e2e/demo-e2e-distributed/src/main.ts @@ -0,0 +1,38 @@ +import { FrontMcp, LogLevel } from '@frontmcp/sdk'; + +import { DistributedApp } from './apps/distributed-app'; + +const port = parseInt(process.env['PORT'] ?? '3200', 10); +const redisUrl = process.env['REDIS_URL'] ?? 'redis://localhost:6379'; + +// Parse redis URL into host/port +const redisUrlParsed = new URL(redisUrl); + +@FrontMcp({ + info: { name: 'Demo E2E Distributed', version: '0.1.0' }, + apps: [DistributedApp], + logging: { level: LogLevel.Warn }, + http: { port }, + auth: { + mode: 'public', + sessionTtl: 3600, + anonymousScopes: ['anonymous'], + }, + transport: { + protocol: 'modern', + persistence: { + redis: { + provider: 'redis', + host: redisUrlParsed.hostname, + port: parseInt(redisUrlParsed.port || '6379', 10), + }, + defaultTtlMs: 300_000, + }, + }, + redis: { + provider: 'redis', + host: redisUrlParsed.hostname, + port: parseInt(redisUrlParsed.port || '6379', 10), + }, +}) +export default class Server {} diff --git a/apps/e2e/demo-e2e-distributed/tsconfig.app.json b/apps/e2e/demo-e2e-distributed/tsconfig.app.json new file mode 100644 index 000000000..ac03a0d92 --- /dev/null +++ b/apps/e2e/demo-e2e-distributed/tsconfig.app.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "jsx": "react-jsx", + "types": ["node"], + "emitDecoratorMetadata": true, + "experimentalDecorators": true + }, + "exclude": ["jest.config.ts", "jest.e2e.config.ts", "src/**/*.spec.ts", "src/**/*.spec.tsx", "e2e/**/*.ts"], + "include": ["src/**/*.ts", "src/**/*.tsx"] +} diff --git a/apps/e2e/demo-e2e-distributed/tsconfig.json b/apps/e2e/demo-e2e-distributed/tsconfig.json new file mode 100644 index 000000000..f2fd67cbf --- /dev/null +++ b/apps/e2e/demo-e2e-distributed/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "esModuleInterop": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.app.json" + } + ], + "extends": "../../../tsconfig.base.json" +} diff --git a/apps/e2e/demo-e2e-distributed/webpack.config.js b/apps/e2e/demo-e2e-distributed/webpack.config.js new file mode 100644 index 000000000..2279e6c30 --- /dev/null +++ b/apps/e2e/demo-e2e-distributed/webpack.config.js @@ -0,0 +1,28 @@ +const { NxAppWebpackPlugin } = require('@nx/webpack/app-plugin'); +const { join } = require('path'); + +module.exports = { + output: { + path: join(__dirname, '../../../dist/apps/e2e/demo-e2e-distributed'), + ...(process.env.NODE_ENV !== 'production' && { + devtoolModuleFilenameTemplate: '[absolute-resource-path]', + }), + }, + mode: 'development', + devtool: 'eval-cheap-module-source-map', + plugins: [ + new NxAppWebpackPlugin({ + target: 'node', + compiler: 'tsc', + main: './src/main.ts', + sourceMap: true, + tsConfig: './tsconfig.app.json', + assets: [], + externalDependencies: 'all', + optimization: false, + outputHashing: 'none', + generatePackageJson: false, + buildLibsFromSource: true, + }), + ], +}; diff --git a/apps/e2e/demo-e2e-elicitation/src/apps/elicitation-demo/tools/confirm-action.tool.ts b/apps/e2e/demo-e2e-elicitation/src/apps/elicitation-demo/tools/confirm-action.tool.ts index 4cf939da4..fc84241a0 100644 --- a/apps/e2e/demo-e2e-elicitation/src/apps/elicitation-demo/tools/confirm-action.tool.ts +++ b/apps/e2e/demo-e2e-elicitation/src/apps/elicitation-demo/tools/confirm-action.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = { action: z.string().describe('Action to confirm'), }; @@ -19,7 +20,7 @@ type Output = z.infer; inputSchema, outputSchema, }) -export default class ConfirmActionTool extends ToolContext { +export default class ConfirmActionTool extends ToolContext { async execute(input: Input): Promise { const result = await this.elicit( `Do you want to proceed with: ${input.action}?`, diff --git a/apps/e2e/demo-e2e-elicitation/src/apps/elicitation-demo/tools/get-user-input.tool.ts b/apps/e2e/demo-e2e-elicitation/src/apps/elicitation-demo/tools/get-user-input.tool.ts index 7df1b7cac..c61f60a65 100644 --- a/apps/e2e/demo-e2e-elicitation/src/apps/elicitation-demo/tools/get-user-input.tool.ts +++ b/apps/e2e/demo-e2e-elicitation/src/apps/elicitation-demo/tools/get-user-input.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = { prompt: z.string().describe('Prompt to show user'), }; @@ -20,7 +21,7 @@ type Output = z.infer; inputSchema, outputSchema, }) -export default class GetUserInputTool extends ToolContext { +export default class GetUserInputTool extends ToolContext { async execute(input: Input): Promise { const result = await this.elicit( input.prompt, diff --git a/apps/e2e/demo-e2e-elicitation/src/apps/elicitation-demo/tools/multi-step.tool.ts b/apps/e2e/demo-e2e-elicitation/src/apps/elicitation-demo/tools/multi-step.tool.ts index 05baef046..12e8460be 100644 --- a/apps/e2e/demo-e2e-elicitation/src/apps/elicitation-demo/tools/multi-step.tool.ts +++ b/apps/e2e/demo-e2e-elicitation/src/apps/elicitation-demo/tools/multi-step.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = {}; const outputSchema = z.object({ @@ -20,7 +21,7 @@ type Output = z.infer; inputSchema, outputSchema, }) -export default class MultiStepWizardTool extends ToolContext { +export default class MultiStepWizardTool extends ToolContext { async execute(_input: Input): Promise { // Step 1: Get name const step1 = await this.elicit( diff --git a/apps/e2e/demo-e2e-errors/src/apps/errors/tools/successful.tool.ts b/apps/e2e/demo-e2e-errors/src/apps/errors/tools/successful.tool.ts index f86083251..4e3eef3f4 100644 --- a/apps/e2e/demo-e2e-errors/src/apps/errors/tools/successful.tool.ts +++ b/apps/e2e/demo-e2e-errors/src/apps/errors/tools/successful.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = { message: z.string().default('Success').describe('Success message'), }; @@ -21,7 +22,7 @@ type Output = z.infer; inputSchema, outputSchema, }) -export default class SuccessfulTool extends ToolContext { +export default class SuccessfulTool extends ToolContext { async execute(input: Input): Promise { return { success: true, diff --git a/apps/e2e/demo-e2e-errors/src/apps/errors/tools/throw-custom-error.tool.ts b/apps/e2e/demo-e2e-errors/src/apps/errors/tools/throw-custom-error.tool.ts index 9593cfac3..794692d4a 100644 --- a/apps/e2e/demo-e2e-errors/src/apps/errors/tools/throw-custom-error.tool.ts +++ b/apps/e2e/demo-e2e-errors/src/apps/errors/tools/throw-custom-error.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext, PublicMcpError } from '@frontmcp/sdk'; import { z } from 'zod'; +import { PublicMcpError, Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = { errorCode: z.string().describe('Custom error code'), errorMessage: z.string().describe('Custom error message'), @@ -23,7 +24,7 @@ type Output = z.infer; inputSchema, outputSchema, }) -export default class ThrowCustomErrorTool extends ToolContext { +export default class ThrowCustomErrorTool extends ToolContext { async execute(input: Input): Promise { if (input.trigger) { throw new PublicMcpError(input.errorMessage, input.errorCode, input.statusCode); diff --git a/apps/e2e/demo-e2e-errors/src/apps/errors/tools/throw-internal-error.tool.ts b/apps/e2e/demo-e2e-errors/src/apps/errors/tools/throw-internal-error.tool.ts index b119a3251..570d27df5 100644 --- a/apps/e2e/demo-e2e-errors/src/apps/errors/tools/throw-internal-error.tool.ts +++ b/apps/e2e/demo-e2e-errors/src/apps/errors/tools/throw-internal-error.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext, InternalMcpError } from '@frontmcp/sdk'; import { z } from 'zod'; +import { InternalMcpError, Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = { trigger: z.boolean().default(true).describe('Whether to trigger the internal error'), }; @@ -20,7 +21,7 @@ type Output = z.infer; inputSchema, outputSchema, }) -export default class ThrowInternalErrorTool extends ToolContext { +export default class ThrowInternalErrorTool extends ToolContext { async execute(input: Input): Promise { if (input.trigger) { throw new InternalMcpError('Simulated internal server error', 'SIMULATED_INTERNAL_ERROR'); diff --git a/apps/e2e/demo-e2e-errors/src/apps/errors/tools/throw-not-found.tool.ts b/apps/e2e/demo-e2e-errors/src/apps/errors/tools/throw-not-found.tool.ts index 8b974e557..4b5ca16c9 100644 --- a/apps/e2e/demo-e2e-errors/src/apps/errors/tools/throw-not-found.tool.ts +++ b/apps/e2e/demo-e2e-errors/src/apps/errors/tools/throw-not-found.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext, ResourceNotFoundError } from '@frontmcp/sdk'; import { z } from 'zod'; +import { ResourceNotFoundError, Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = { resourceId: z.string().describe('Resource ID to look up'), }; @@ -24,7 +25,7 @@ const existingResources = new Set(['resource-1', 'resource-2', 'resource-3']); inputSchema, outputSchema, }) -export default class ThrowNotFoundTool extends ToolContext { +export default class ThrowNotFoundTool extends ToolContext { async execute(input: Input): Promise { if (!existingResources.has(input.resourceId)) { throw new ResourceNotFoundError(`resource://${input.resourceId}`); diff --git a/apps/e2e/demo-e2e-errors/src/apps/errors/tools/throw-validation-error.tool.ts b/apps/e2e/demo-e2e-errors/src/apps/errors/tools/throw-validation-error.tool.ts index e5ae2067f..e7597387c 100644 --- a/apps/e2e/demo-e2e-errors/src/apps/errors/tools/throw-validation-error.tool.ts +++ b/apps/e2e/demo-e2e-errors/src/apps/errors/tools/throw-validation-error.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext, InvalidInputError } from '@frontmcp/sdk'; import { z } from 'zod'; +import { InvalidInputError, Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = { value: z.string().describe('Value to validate'), minLength: z.number().int().min(1).default(5).describe('Minimum length required'), @@ -22,7 +23,7 @@ type Output = z.infer; inputSchema, outputSchema, }) -export default class ThrowValidationErrorTool extends ToolContext { +export default class ThrowValidationErrorTool extends ToolContext { async execute(input: Input): Promise { if (input.value.length < input.minLength) { throw new InvalidInputError(`Value must be at least ${input.minLength} characters`, { diff --git a/apps/e2e/demo-e2e-feature-flags/src/apps/flagged/tools/always-enabled.tool.ts b/apps/e2e/demo-e2e-feature-flags/src/apps/flagged/tools/always-enabled.tool.ts index e19cafeb9..2f0651eef 100644 --- a/apps/e2e/demo-e2e-feature-flags/src/apps/flagged/tools/always-enabled.tool.ts +++ b/apps/e2e/demo-e2e-feature-flags/src/apps/flagged/tools/always-enabled.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = { message: z.string().describe('Message to echo'), }; @@ -20,7 +21,7 @@ type Output = z.infer; outputSchema, featureFlag: 'always-on', }) -export default class AlwaysEnabledTool extends ToolContext { +export default class AlwaysEnabledTool extends ToolContext { async execute(input: Input): Promise { return { message: `[always-enabled] ${input.message}`, diff --git a/apps/e2e/demo-e2e-feature-flags/src/apps/flagged/tools/beta-search.tool.ts b/apps/e2e/demo-e2e-feature-flags/src/apps/flagged/tools/beta-search.tool.ts index 39486aee4..f1b7d2408 100644 --- a/apps/e2e/demo-e2e-feature-flags/src/apps/flagged/tools/beta-search.tool.ts +++ b/apps/e2e/demo-e2e-feature-flags/src/apps/flagged/tools/beta-search.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = { query: z.string().describe('Search query'), }; @@ -20,7 +21,7 @@ type Output = z.infer; outputSchema, featureFlag: 'beta-search', }) -export default class BetaSearchTool extends ToolContext { +export default class BetaSearchTool extends ToolContext { async execute(input: Input): Promise { return { results: [`Result for: ${input.query}`], diff --git a/apps/e2e/demo-e2e-feature-flags/src/apps/flagged/tools/check-flag.tool.ts b/apps/e2e/demo-e2e-feature-flags/src/apps/flagged/tools/check-flag.tool.ts index 6ff99cb84..3a82b6536 100644 --- a/apps/e2e/demo-e2e-feature-flags/src/apps/flagged/tools/check-flag.tool.ts +++ b/apps/e2e/demo-e2e-feature-flags/src/apps/flagged/tools/check-flag.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = { flagKey: z.string().describe('Feature flag key to check programmatically'), }; @@ -19,7 +20,7 @@ type Output = z.infer; inputSchema, outputSchema, }) -export default class CheckFlagTool extends ToolContext { +export default class CheckFlagTool extends ToolContext { async execute(input: Input): Promise { const isEnabled = await this.featureFlags.isEnabled(input.flagKey); return { diff --git a/apps/e2e/demo-e2e-feature-flags/src/apps/flagged/tools/default-true.tool.ts b/apps/e2e/demo-e2e-feature-flags/src/apps/flagged/tools/default-true.tool.ts index f28cfd9c7..e94fbeabb 100644 --- a/apps/e2e/demo-e2e-feature-flags/src/apps/flagged/tools/default-true.tool.ts +++ b/apps/e2e/demo-e2e-feature-flags/src/apps/flagged/tools/default-true.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = {}; const outputSchema = z.object({ @@ -18,7 +19,7 @@ type Output = z.infer; outputSchema, featureFlag: { key: 'nonexistent-flag', defaultValue: true }, }) -export default class DefaultTrueTool extends ToolContext { +export default class DefaultTrueTool extends ToolContext { async execute(_input: Input): Promise { return { status: 'accessible via defaultValue', diff --git a/apps/e2e/demo-e2e-feature-flags/src/apps/flagged/tools/experimental-agent.tool.ts b/apps/e2e/demo-e2e-feature-flags/src/apps/flagged/tools/experimental-agent.tool.ts index b1b4a1bf8..c6709cf44 100644 --- a/apps/e2e/demo-e2e-feature-flags/src/apps/flagged/tools/experimental-agent.tool.ts +++ b/apps/e2e/demo-e2e-feature-flags/src/apps/flagged/tools/experimental-agent.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = { task: z.string().describe('Task to perform'), }; @@ -20,7 +21,7 @@ type Output = z.infer; outputSchema, featureFlag: 'experimental-agent', }) -export default class ExperimentalAgentTool extends ToolContext { +export default class ExperimentalAgentTool extends ToolContext { async execute(input: Input): Promise { return { result: `Executed: ${input.task}`, diff --git a/apps/e2e/demo-e2e-feature-flags/src/apps/flagged/tools/unflagged.tool.ts b/apps/e2e/demo-e2e-feature-flags/src/apps/flagged/tools/unflagged.tool.ts index 20c0666df..838638558 100644 --- a/apps/e2e/demo-e2e-feature-flags/src/apps/flagged/tools/unflagged.tool.ts +++ b/apps/e2e/demo-e2e-feature-flags/src/apps/flagged/tools/unflagged.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = {}; const outputSchema = z.object({ @@ -16,7 +17,7 @@ type Output = z.infer; inputSchema, outputSchema, }) -export default class UnflaggedTool extends ToolContext { +export default class UnflaggedTool extends ToolContext { async execute(_input: Input): Promise { return { status: 'always available', diff --git a/apps/e2e/demo-e2e-guard/fixture/src/main.ts b/apps/e2e/demo-e2e-guard/fixture/src/main.ts index f8e746e97..695670505 100644 --- a/apps/e2e/demo-e2e-guard/fixture/src/main.ts +++ b/apps/e2e/demo-e2e-guard/fixture/src/main.ts @@ -1,7 +1,9 @@ import 'reflect-metadata'; -import { LogLevel, Tool, ToolContext, App } from '@frontmcp/sdk'; + import { z } from 'zod'; +import { App, LogLevel, Tool, ToolContext } from '@frontmcp/sdk'; + const messageSchema = { message: z.string().default('hello') }; const delaySchema = { delayMs: z.number().default(0) }; const valueSchema = { value: z.string().default('test') }; @@ -12,7 +14,7 @@ const valueSchema = { value: z.string().default('test') }; inputSchema: messageSchema, rateLimit: { maxRequests: 3, windowMs: 5000, partitionBy: 'global' }, }) -class RateLimitedTool extends ToolContext { +class RateLimitedTool extends ToolContext { async execute(input: { message: string }) { return { echo: input.message }; } @@ -24,7 +26,7 @@ class RateLimitedTool extends ToolContext { inputSchema: delaySchema, timeout: { executeMs: 500 }, }) -class TimeoutTool extends ToolContext { +class TimeoutTool extends ToolContext { async execute(input: { delayMs: number }) { if (input.delayMs > 0) { await new Promise((resolve) => setTimeout(resolve, input.delayMs)); @@ -38,7 +40,7 @@ class TimeoutTool extends ToolContext { description: 'An unguarded echo tool', inputSchema: valueSchema, }) -class UnguardedTool extends ToolContext { +class UnguardedTool extends ToolContext { async execute(input: { value: string }) { return { echo: input.value }; } @@ -50,7 +52,7 @@ class UnguardedTool extends ToolContext { inputSchema: delaySchema, concurrency: { maxConcurrent: 1, queueTimeoutMs: 0 }, }) -class ConcurrencyMutexTool extends ToolContext { +class ConcurrencyMutexTool extends ToolContext { async execute(input: { delayMs: number }) { if (input.delayMs > 0) { await new Promise((resolve) => setTimeout(resolve, input.delayMs)); @@ -65,7 +67,7 @@ class ConcurrencyMutexTool extends ToolContext { inputSchema: delaySchema, concurrency: { maxConcurrent: 1, queueTimeoutMs: 3000 }, }) -class ConcurrencyQueuedTool extends ToolContext { +class ConcurrencyQueuedTool extends ToolContext { async execute(input: { delayMs: number }) { if (input.delayMs > 0) { await new Promise((resolve) => setTimeout(resolve, input.delayMs)); @@ -82,7 +84,7 @@ class ConcurrencyQueuedTool extends ToolContext { concurrency: { maxConcurrent: 2, queueTimeoutMs: 1000 }, timeout: { executeMs: 2000 }, }) -class CombinedGuardTool extends ToolContext { +class CombinedGuardTool extends ToolContext { async execute(input: { delayMs: number }) { if (input.delayMs > 0) { await new Promise((resolve) => setTimeout(resolve, input.delayMs)); @@ -96,7 +98,7 @@ class CombinedGuardTool extends ToolContext { description: 'A slow tool', inputSchema: delaySchema, }) -class SlowTool extends ToolContext { +class SlowTool extends ToolContext { async execute(input: { delayMs: number }) { if (input.delayMs > 0) { await new Promise((resolve) => setTimeout(resolve, input.delayMs)); diff --git a/apps/e2e/demo-e2e-guard/src/apps/guard/tools/combined-guard.tool.ts b/apps/e2e/demo-e2e-guard/src/apps/guard/tools/combined-guard.tool.ts index b79aa67e2..7368de796 100644 --- a/apps/e2e/demo-e2e-guard/src/apps/guard/tools/combined-guard.tool.ts +++ b/apps/e2e/demo-e2e-guard/src/apps/guard/tools/combined-guard.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = { delayMs: z.number().default(0), }; @@ -24,7 +25,7 @@ type Input = z.infer>; executeMs: 2000, }, }) -export default class CombinedGuardTool extends ToolContext { +export default class CombinedGuardTool extends ToolContext { async execute(input: Input) { if (input.delayMs > 0) { await new Promise((resolve) => setTimeout(resolve, input.delayMs)); diff --git a/apps/e2e/demo-e2e-guard/src/apps/guard/tools/concurrency-mutex.tool.ts b/apps/e2e/demo-e2e-guard/src/apps/guard/tools/concurrency-mutex.tool.ts index 4f10cd2f8..5f290019a 100644 --- a/apps/e2e/demo-e2e-guard/src/apps/guard/tools/concurrency-mutex.tool.ts +++ b/apps/e2e/demo-e2e-guard/src/apps/guard/tools/concurrency-mutex.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = { delayMs: z.number().default(0), }; @@ -16,7 +17,7 @@ type Input = z.infer>; queueTimeoutMs: 0, }, }) -export default class ConcurrencyMutexTool extends ToolContext { +export default class ConcurrencyMutexTool extends ToolContext { async execute(input: Input) { if (input.delayMs > 0) { await new Promise((resolve) => setTimeout(resolve, input.delayMs)); diff --git a/apps/e2e/demo-e2e-guard/src/apps/guard/tools/concurrency-queued.tool.ts b/apps/e2e/demo-e2e-guard/src/apps/guard/tools/concurrency-queued.tool.ts index 77b274c89..93039381d 100644 --- a/apps/e2e/demo-e2e-guard/src/apps/guard/tools/concurrency-queued.tool.ts +++ b/apps/e2e/demo-e2e-guard/src/apps/guard/tools/concurrency-queued.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = { delayMs: z.number().default(0), }; @@ -16,7 +17,7 @@ type Input = z.infer>; queueTimeoutMs: 3000, }, }) -export default class ConcurrencyQueuedTool extends ToolContext { +export default class ConcurrencyQueuedTool extends ToolContext { async execute(input: Input) { if (input.delayMs > 0) { await new Promise((resolve) => setTimeout(resolve, input.delayMs)); diff --git a/apps/e2e/demo-e2e-guard/src/apps/guard/tools/rate-limited.tool.ts b/apps/e2e/demo-e2e-guard/src/apps/guard/tools/rate-limited.tool.ts index b0ccf305e..e5a2a79fc 100644 --- a/apps/e2e/demo-e2e-guard/src/apps/guard/tools/rate-limited.tool.ts +++ b/apps/e2e/demo-e2e-guard/src/apps/guard/tools/rate-limited.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = { message: z.string().default('hello'), }; @@ -17,7 +18,7 @@ type Input = z.infer>; partitionBy: 'global', }, }) -export default class RateLimitedTool extends ToolContext { +export default class RateLimitedTool extends ToolContext { async execute(input: Input) { return { echo: input.message }; } diff --git a/apps/e2e/demo-e2e-guard/src/apps/guard/tools/slow.tool.ts b/apps/e2e/demo-e2e-guard/src/apps/guard/tools/slow.tool.ts index a36eb8d78..fcd124f26 100644 --- a/apps/e2e/demo-e2e-guard/src/apps/guard/tools/slow.tool.ts +++ b/apps/e2e/demo-e2e-guard/src/apps/guard/tools/slow.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = { delayMs: z.number().default(0), }; @@ -12,7 +13,7 @@ type Input = z.infer>; description: 'A slow tool that inherits the default 5000ms app timeout', inputSchema, }) -export default class SlowTool extends ToolContext { +export default class SlowTool extends ToolContext { async execute(input: Input) { if (input.delayMs > 0) { await new Promise((resolve) => setTimeout(resolve, input.delayMs)); diff --git a/apps/e2e/demo-e2e-guard/src/apps/guard/tools/timeout.tool.ts b/apps/e2e/demo-e2e-guard/src/apps/guard/tools/timeout.tool.ts index 33ef2b391..4d0effdfa 100644 --- a/apps/e2e/demo-e2e-guard/src/apps/guard/tools/timeout.tool.ts +++ b/apps/e2e/demo-e2e-guard/src/apps/guard/tools/timeout.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = { delayMs: z.number().default(0), }; @@ -15,7 +16,7 @@ type Input = z.infer>; executeMs: 500, }, }) -export default class TimeoutTool extends ToolContext { +export default class TimeoutTool extends ToolContext { async execute(input: Input) { if (input.delayMs > 0) { await new Promise((resolve) => setTimeout(resolve, input.delayMs)); diff --git a/apps/e2e/demo-e2e-guard/src/apps/guard/tools/unguarded.tool.ts b/apps/e2e/demo-e2e-guard/src/apps/guard/tools/unguarded.tool.ts index 78855448d..f12878be1 100644 --- a/apps/e2e/demo-e2e-guard/src/apps/guard/tools/unguarded.tool.ts +++ b/apps/e2e/demo-e2e-guard/src/apps/guard/tools/unguarded.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = { value: z.string().default('test'), }; @@ -12,7 +13,7 @@ type Input = z.infer>; description: 'An unguarded echo tool (no rate limit, no concurrency, no timeout)', inputSchema, }) -export default class UnguardedTool extends ToolContext { +export default class UnguardedTool extends ToolContext { async execute(input: Input) { return { echo: input.value }; } diff --git a/apps/e2e/demo-e2e-hooks/src/apps/audit/tools/audited.tool.ts b/apps/e2e/demo-e2e-hooks/src/apps/audit/tools/audited.tool.ts index eb08cdcd9..78b95370c 100644 --- a/apps/e2e/demo-e2e-hooks/src/apps/audit/tools/audited.tool.ts +++ b/apps/e2e/demo-e2e-hooks/src/apps/audit/tools/audited.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = { message: z.string().describe('Message to process'), delay: z.number().int().min(0).default(0).describe('Optional delay in ms to simulate work'), @@ -23,7 +24,7 @@ type Output = z.infer; inputSchema, outputSchema, }) -export default class AuditedTool extends ToolContext { +export default class AuditedTool extends ToolContext { async execute(input: Input): Promise { // Simulate work if (input.delay > 0) { diff --git a/apps/e2e/demo-e2e-hooks/src/apps/audit/tools/clear-audit-log.tool.ts b/apps/e2e/demo-e2e-hooks/src/apps/audit/tools/clear-audit-log.tool.ts index d3cadec09..c96c986da 100644 --- a/apps/e2e/demo-e2e-hooks/src/apps/audit/tools/clear-audit-log.tool.ts +++ b/apps/e2e/demo-e2e-hooks/src/apps/audit/tools/clear-audit-log.tool.ts @@ -1,5 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import { auditLog } from '../data/audit-log'; const inputSchema = {}; @@ -18,7 +20,7 @@ type Output = z.infer; inputSchema, outputSchema, }) -export default class ClearAuditLogTool extends ToolContext { +export default class ClearAuditLogTool extends ToolContext { async execute(_input: Input): Promise { auditLog.clear(); diff --git a/apps/e2e/demo-e2e-hooks/src/apps/audit/tools/get-audit-log.tool.ts b/apps/e2e/demo-e2e-hooks/src/apps/audit/tools/get-audit-log.tool.ts index 6d8bfc35c..bf271d1cc 100644 --- a/apps/e2e/demo-e2e-hooks/src/apps/audit/tools/get-audit-log.tool.ts +++ b/apps/e2e/demo-e2e-hooks/src/apps/audit/tools/get-audit-log.tool.ts @@ -1,5 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import { auditLog } from '../data/audit-log'; const inputSchema = { @@ -36,7 +38,7 @@ type Output = z.infer; inputSchema, outputSchema, }) -export default class GetAuditLogTool extends ToolContext { +export default class GetAuditLogTool extends ToolContext { async execute(input: Input): Promise { const entries = input.toolName ? auditLog.getEntriesForTool(input.toolName) : auditLog.getEntries(); diff --git a/apps/e2e/demo-e2e-hr/src/apps/hr/tools/employee-directory.tool.ts b/apps/e2e/demo-e2e-hr/src/apps/hr/tools/employee-directory.tool.ts index 4c4fa4bd3..614d469d4 100644 --- a/apps/e2e/demo-e2e-hr/src/apps/hr/tools/employee-directory.tool.ts +++ b/apps/e2e/demo-e2e-hr/src/apps/hr/tools/employee-directory.tool.ts @@ -1,6 +1,8 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; -import { EMPLOYEES, DEPARTMENTS } from '../data/employees'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + +import { DEPARTMENTS, EMPLOYEES } from '../data/employees'; const inputSchema = { department: z.enum(DEPARTMENTS).optional().describe('Filter by department'), @@ -44,7 +46,7 @@ type Output = z.infer; }, }, }) -export default class EmployeeDirectoryTool extends ToolContext { +export default class EmployeeDirectoryTool extends ToolContext { async execute(input: Input): Promise { let filtered = EMPLOYEES; if (input.department) filtered = filtered.filter((e) => e.department === input.department); diff --git a/apps/e2e/demo-e2e-hr/src/apps/hr/tools/employee-profile.tool.ts b/apps/e2e/demo-e2e-hr/src/apps/hr/tools/employee-profile.tool.ts index a93a9bbfc..0dd1a23a8 100644 --- a/apps/e2e/demo-e2e-hr/src/apps/hr/tools/employee-profile.tool.ts +++ b/apps/e2e/demo-e2e-hr/src/apps/hr/tools/employee-profile.tool.ts @@ -1,5 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import { EMPLOYEES, SALARY_BANDS } from '../data/employees'; const inputSchema = { @@ -47,7 +49,7 @@ type Output = z.infer; resourceMode: 'cdn', }, }) -export default class EmployeeProfileTool extends ToolContext { +export default class EmployeeProfileTool extends ToolContext { async execute(input: Input): Promise { const emp = EMPLOYEES.find((e) => e.id === input.employeeId); if (!emp) { diff --git a/apps/e2e/demo-e2e-hr/src/apps/hr/tools/generate-offer-letter.tool.ts b/apps/e2e/demo-e2e-hr/src/apps/hr/tools/generate-offer-letter.tool.ts index ec2e6b43f..40fb07f58 100644 --- a/apps/e2e/demo-e2e-hr/src/apps/hr/tools/generate-offer-letter.tool.ts +++ b/apps/e2e/demo-e2e-hr/src/apps/hr/tools/generate-offer-letter.tool.ts @@ -1,5 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import { SALARY_BANDS } from '../data/employees'; const inputSchema = { @@ -127,7 +129,7 @@ function buildPdf(name: string, role: string, department: string, salaryRange: s }, }, }) -export default class GenerateOfferLetterTool extends ToolContext { +export default class GenerateOfferLetterTool extends ToolContext { async execute(input: Input): Promise { const band = SALARY_BANDS[input.salaryBand]; const salaryRange = band ? `$${(band.min / 1000).toFixed(0)}K - $${(band.max / 1000).toFixed(0)}K` : 'Competitive'; diff --git a/apps/e2e/demo-e2e-hr/src/apps/hr/tools/headcount-by-department.tool.ts b/apps/e2e/demo-e2e-hr/src/apps/hr/tools/headcount-by-department.tool.ts index 31d111060..9237a412d 100644 --- a/apps/e2e/demo-e2e-hr/src/apps/hr/tools/headcount-by-department.tool.ts +++ b/apps/e2e/demo-e2e-hr/src/apps/hr/tools/headcount-by-department.tool.ts @@ -1,5 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import { EMPLOYEES } from '../data/employees'; const inputSchema = { @@ -46,7 +48,7 @@ type Output = z.infer; }, }, }) -export default class HeadcountByDepartmentTool extends ToolContext { +export default class HeadcountByDepartmentTool extends ToolContext { async execute(input: Input): Promise { const filtered = input.includeOnLeave ? EMPLOYEES.filter((e) => e.status !== 'offboarding') diff --git a/apps/e2e/demo-e2e-hr/src/apps/hr/tools/org-chart.tool.ts b/apps/e2e/demo-e2e-hr/src/apps/hr/tools/org-chart.tool.ts index 793644ac2..16296181e 100644 --- a/apps/e2e/demo-e2e-hr/src/apps/hr/tools/org-chart.tool.ts +++ b/apps/e2e/demo-e2e-hr/src/apps/hr/tools/org-chart.tool.ts @@ -1,6 +1,8 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; -import { EMPLOYEES, DEPARTMENTS } from '../data/employees'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + +import { DEPARTMENTS, EMPLOYEES } from '../data/employees'; const inputSchema = { department: z.enum(DEPARTMENTS).optional().describe('Filter org chart to a specific department'), @@ -28,7 +30,7 @@ type Output = z.infer; template: (ctx) => (ctx.output as Output).mermaid, }, }) -export default class OrgChartTool extends ToolContext { +export default class OrgChartTool extends ToolContext { async execute(input: Input): Promise { let filtered = EMPLOYEES; if (input.department) { diff --git a/apps/e2e/demo-e2e-hr/src/apps/hr/tools/salary-distribution.tool.ts b/apps/e2e/demo-e2e-hr/src/apps/hr/tools/salary-distribution.tool.ts index 143eb2a9e..fd9bef852 100644 --- a/apps/e2e/demo-e2e-hr/src/apps/hr/tools/salary-distribution.tool.ts +++ b/apps/e2e/demo-e2e-hr/src/apps/hr/tools/salary-distribution.tool.ts @@ -1,6 +1,8 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; -import { EMPLOYEES, SALARY_BANDS, DEPARTMENTS } from '../data/employees'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + +import { DEPARTMENTS, EMPLOYEES, SALARY_BANDS } from '../data/employees'; const inputSchema = { department: z.enum(DEPARTMENTS).optional().describe('Filter by department (all departments if omitted)'), @@ -42,7 +44,7 @@ type Output = z.infer; }, }, }) -export default class SalaryDistributionTool extends ToolContext { +export default class SalaryDistributionTool extends ToolContext { async execute(input: Input): Promise { let filtered = EMPLOYEES; if (input.department) filtered = filtered.filter((e) => e.department === input.department); diff --git a/apps/e2e/demo-e2e-hr/src/apps/hr/tools/team-growth.tool.ts b/apps/e2e/demo-e2e-hr/src/apps/hr/tools/team-growth.tool.ts index 83d964c49..8360f780f 100644 --- a/apps/e2e/demo-e2e-hr/src/apps/hr/tools/team-growth.tool.ts +++ b/apps/e2e/demo-e2e-hr/src/apps/hr/tools/team-growth.tool.ts @@ -1,5 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import { QUARTERLY_HIRING } from '../data/employees'; const inputSchema = { @@ -45,7 +47,7 @@ type Output = z.infer; }, }, }) -export default class TeamGrowthTool extends ToolContext { +export default class TeamGrowthTool extends ToolContext { async execute(input: Input): Promise { const totalHires = QUARTERLY_HIRING.reduce((sum, q) => sum + q.hires, 0); diff --git a/apps/e2e/demo-e2e-multiapp/src/apps/calendar/tools/create-event.tool.ts b/apps/e2e/demo-e2e-multiapp/src/apps/calendar/tools/create-event.tool.ts index 9b39162d8..0d059d618 100644 --- a/apps/e2e/demo-e2e-multiapp/src/apps/calendar/tools/create-event.tool.ts +++ b/apps/e2e/demo-e2e-multiapp/src/apps/calendar/tools/create-event.tool.ts @@ -1,5 +1,7 @@ -import { Tool, ToolContext, InvalidInputError } from '@frontmcp/sdk'; import { z } from 'zod'; + +import { InvalidInputError, Tool, ToolContext } from '@frontmcp/sdk'; + import { eventStore } from '../data/event.store'; const inputSchema = { @@ -29,7 +31,7 @@ type Output = z.infer; inputSchema, outputSchema, }) -export default class CreateEventTool extends ToolContext { +export default class CreateEventTool extends ToolContext { async execute(input: Input): Promise { // Validate time range if (input.startTime >= input.endTime) { diff --git a/apps/e2e/demo-e2e-multiapp/src/apps/calendar/tools/list-events.tool.ts b/apps/e2e/demo-e2e-multiapp/src/apps/calendar/tools/list-events.tool.ts index 44c794df4..544f63166 100644 --- a/apps/e2e/demo-e2e-multiapp/src/apps/calendar/tools/list-events.tool.ts +++ b/apps/e2e/demo-e2e-multiapp/src/apps/calendar/tools/list-events.tool.ts @@ -1,5 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import { eventStore } from '../data/event.store'; const inputSchema = { @@ -29,7 +31,7 @@ type Output = z.infer; inputSchema, outputSchema, }) -export default class ListEventsTool extends ToolContext { +export default class ListEventsTool extends ToolContext { async execute(input: Input): Promise { const store = eventStore; const events = input.upcomingOnly ? store.getUpcoming() : store.getAll(); diff --git a/apps/e2e/demo-e2e-multiapp/src/apps/notes/tools/create-note.tool.ts b/apps/e2e/demo-e2e-multiapp/src/apps/notes/tools/create-note.tool.ts index 46bcadcbe..213dcb8f5 100644 --- a/apps/e2e/demo-e2e-multiapp/src/apps/notes/tools/create-note.tool.ts +++ b/apps/e2e/demo-e2e-multiapp/src/apps/notes/tools/create-note.tool.ts @@ -1,5 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import { noteStore } from '../data/note.store'; const inputSchema = { @@ -23,7 +25,7 @@ type Output = z.infer; inputSchema, outputSchema, }) -export default class CreateNoteTool extends ToolContext { +export default class CreateNoteTool extends ToolContext { async execute(input: Input): Promise { const store = noteStore; const note = store.create(input.title, input.content); diff --git a/apps/e2e/demo-e2e-multiapp/src/apps/notes/tools/list-notes.tool.ts b/apps/e2e/demo-e2e-multiapp/src/apps/notes/tools/list-notes.tool.ts index babab4697..8b275f2a2 100644 --- a/apps/e2e/demo-e2e-multiapp/src/apps/notes/tools/list-notes.tool.ts +++ b/apps/e2e/demo-e2e-multiapp/src/apps/notes/tools/list-notes.tool.ts @@ -1,5 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import { noteStore } from '../data/note.store'; const inputSchema = {}; @@ -25,7 +27,7 @@ type Output = z.infer; inputSchema, outputSchema, }) -export default class ListNotesTool extends ToolContext { +export default class ListNotesTool extends ToolContext { async execute(_input: Input): Promise { const notes = noteStore.getAll(); diff --git a/apps/e2e/demo-e2e-multiapp/src/apps/tasks/tools/create-task.tool.ts b/apps/e2e/demo-e2e-multiapp/src/apps/tasks/tools/create-task.tool.ts index 49faaf716..e4ef293f0 100644 --- a/apps/e2e/demo-e2e-multiapp/src/apps/tasks/tools/create-task.tool.ts +++ b/apps/e2e/demo-e2e-multiapp/src/apps/tasks/tools/create-task.tool.ts @@ -1,6 +1,8 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; -import { taskStore, TaskPriority } from '../data/task.store'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + +import { taskStore, type TaskPriority } from '../data/task.store'; const inputSchema = { title: z.string().min(1).describe('Task title'), @@ -26,7 +28,7 @@ type Output = z.infer; inputSchema, outputSchema, }) -export default class CreateTaskTool extends ToolContext { +export default class CreateTaskTool extends ToolContext { async execute(input: Input): Promise { const store = taskStore; const task = store.create(input.title, input.description, input.priority as TaskPriority); diff --git a/apps/e2e/demo-e2e-multiapp/src/apps/tasks/tools/list-tasks.tool.ts b/apps/e2e/demo-e2e-multiapp/src/apps/tasks/tools/list-tasks.tool.ts index 48d934825..47b28fc8c 100644 --- a/apps/e2e/demo-e2e-multiapp/src/apps/tasks/tools/list-tasks.tool.ts +++ b/apps/e2e/demo-e2e-multiapp/src/apps/tasks/tools/list-tasks.tool.ts @@ -1,6 +1,8 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; -import { taskStore, TaskPriority } from '../data/task.store'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + +import { taskStore, type TaskPriority } from '../data/task.store'; const inputSchema = { priority: z.enum(['low', 'medium', 'high', 'all']).default('all').describe('Filter by priority'), @@ -29,7 +31,7 @@ type Output = z.infer; inputSchema, outputSchema, }) -export default class ListTasksTool extends ToolContext { +export default class ListTasksTool extends ToolContext { async execute(input: Input): Promise { const tasks = input.priority === 'all' ? taskStore.getAll() : taskStore.getByPriority(input.priority as TaskPriority); diff --git a/apps/e2e/demo-e2e-notifications/src/apps/notify/tools/long-running-task.tool.ts b/apps/e2e/demo-e2e-notifications/src/apps/notify/tools/long-running-task.tool.ts index 1cbbce59d..767686bb3 100644 --- a/apps/e2e/demo-e2e-notifications/src/apps/notify/tools/long-running-task.tool.ts +++ b/apps/e2e/demo-e2e-notifications/src/apps/notify/tools/long-running-task.tool.ts @@ -1,5 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import { notificationLogStore } from '../data/notification-log.store'; const inputSchema = { @@ -23,7 +25,7 @@ type Output = z.infer; inputSchema, outputSchema, }) -export default class LongRunningTaskTool extends ToolContext { +export default class LongRunningTaskTool extends ToolContext { async execute(input: Input): Promise { const progressLogs: string[] = []; diff --git a/apps/e2e/demo-e2e-notifications/src/apps/notify/tools/test-notify-method.tool.ts b/apps/e2e/demo-e2e-notifications/src/apps/notify/tools/test-notify-method.tool.ts index 339d1f562..44ebf1cd0 100644 --- a/apps/e2e/demo-e2e-notifications/src/apps/notify/tools/test-notify-method.tool.ts +++ b/apps/e2e/demo-e2e-notifications/src/apps/notify/tools/test-notify-method.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = { message: z.string().describe('The message to send'), useObject: z.boolean().optional().describe('If true, send structured object instead of string'), @@ -28,7 +29,7 @@ type Output = z.infer; inputSchema, outputSchema, }) -export default class TestNotifyMethodTool extends ToolContext { +export default class TestNotifyMethodTool extends ToolContext { async execute(input: Input): Promise { let sent: boolean; diff --git a/apps/e2e/demo-e2e-notifications/src/apps/notify/tools/test-progress-method.tool.ts b/apps/e2e/demo-e2e-notifications/src/apps/notify/tools/test-progress-method.tool.ts index d4ed9c8f0..c207a91b0 100644 --- a/apps/e2e/demo-e2e-notifications/src/apps/notify/tools/test-progress-method.tool.ts +++ b/apps/e2e/demo-e2e-notifications/src/apps/notify/tools/test-progress-method.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = { steps: z.number().int().min(1).max(10).describe('Number of progress steps to send'), delayMs: z.number().int().min(0).max(500).optional().describe('Delay between steps in milliseconds'), @@ -28,7 +29,7 @@ type Output = z.infer; inputSchema, outputSchema, }) -export default class TestProgressMethodTool extends ToolContext { +export default class TestProgressMethodTool extends ToolContext { async execute(input: Input): Promise { let progressSent = 0; const total = input.includeTotal ? input.steps : undefined; diff --git a/apps/e2e/demo-e2e-notifications/src/apps/notify/tools/trigger-progress.tool.ts b/apps/e2e/demo-e2e-notifications/src/apps/notify/tools/trigger-progress.tool.ts index 6adfcaecd..dc3bbe30e 100644 --- a/apps/e2e/demo-e2e-notifications/src/apps/notify/tools/trigger-progress.tool.ts +++ b/apps/e2e/demo-e2e-notifications/src/apps/notify/tools/trigger-progress.tool.ts @@ -1,5 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import { notificationLogStore } from '../data/notification-log.store'; const inputSchema = { @@ -23,7 +25,7 @@ type Output = z.infer; inputSchema, outputSchema, }) -export default class TriggerProgressTool extends ToolContext { +export default class TriggerProgressTool extends ToolContext { async execute(input: Input): Promise { // Send log message notification this.scope.notifications.sendLogMessage(input.level ?? 'info', 'notify-app', { diff --git a/apps/e2e/demo-e2e-notifications/src/apps/notify/tools/trigger-resource-change.tool.ts b/apps/e2e/demo-e2e-notifications/src/apps/notify/tools/trigger-resource-change.tool.ts index 6e283e1b5..a7bd92f22 100644 --- a/apps/e2e/demo-e2e-notifications/src/apps/notify/tools/trigger-resource-change.tool.ts +++ b/apps/e2e/demo-e2e-notifications/src/apps/notify/tools/trigger-resource-change.tool.ts @@ -1,5 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import { notificationLogStore } from '../data/notification-log.store'; const inputSchema = { @@ -21,7 +23,7 @@ type Output = z.infer; inputSchema, outputSchema, }) -export default class TriggerResourceChangeTool extends ToolContext { +export default class TriggerResourceChangeTool extends ToolContext { async execute(input: Input): Promise { // Broadcast resource list changed notification this.scope.notifications.broadcastNotification('notifications/resources/list_changed'); diff --git a/apps/e2e/demo-e2e-providers/src/apps/config/tools/get-app-info.tool.ts b/apps/e2e/demo-e2e-providers/src/apps/config/tools/get-app-info.tool.ts index dccb5818d..224467ae0 100644 --- a/apps/e2e/demo-e2e-providers/src/apps/config/tools/get-app-info.tool.ts +++ b/apps/e2e/demo-e2e-providers/src/apps/config/tools/get-app-info.tool.ts @@ -1,5 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import { AppConfigProvider } from '../providers/app-config.provider'; const inputSchema = {}; @@ -25,7 +27,7 @@ let appConfigProviderInstance: AppConfigProvider | null = null; inputSchema, outputSchema, }) -export default class GetAppInfoTool extends ToolContext { +export default class GetAppInfoTool extends ToolContext { async execute(input: Input): Promise { // Get the GLOBAL scope provider - use singleton pattern if (!appConfigProviderInstance) { diff --git a/apps/e2e/demo-e2e-providers/src/apps/config/tools/get-request-info.tool.ts b/apps/e2e/demo-e2e-providers/src/apps/config/tools/get-request-info.tool.ts index efb3cfdcf..b0ba9979f 100644 --- a/apps/e2e/demo-e2e-providers/src/apps/config/tools/get-request-info.tool.ts +++ b/apps/e2e/demo-e2e-providers/src/apps/config/tools/get-request-info.tool.ts @@ -1,6 +1,8 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; -import { REQUEST_LOGGER_TOKEN, RequestLogger } from '../providers/request-logger.provider'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + +import { REQUEST_LOGGER_TOKEN, type RequestLogger } from '../providers/request-logger.provider'; const inputSchema = { logMessage: z.string().optional().describe('Optional message to log'), @@ -24,7 +26,7 @@ type Output = z.infer; inputSchema, outputSchema, }) -export default class GetRequestInfoTool extends ToolContext { +export default class GetRequestInfoTool extends ToolContext { async execute(input: Input): Promise { // Get the CONTEXT scope provider - new instance per request const logger = this.get(REQUEST_LOGGER_TOKEN); diff --git a/apps/e2e/demo-e2e-public/src/apps/notes/tools/create-note.tool.ts b/apps/e2e/demo-e2e-public/src/apps/notes/tools/create-note.tool.ts index 32cf84a51..5cafde633 100644 --- a/apps/e2e/demo-e2e-public/src/apps/notes/tools/create-note.tool.ts +++ b/apps/e2e/demo-e2e-public/src/apps/notes/tools/create-note.tool.ts @@ -1,5 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import { notesStore } from '../data/notes.store'; const inputSchema = { @@ -23,7 +25,7 @@ type CreateNoteOutput = z.infer; inputSchema, outputSchema, }) -export default class CreateNoteTool extends ToolContext { +export default class CreateNoteTool extends ToolContext { async execute(input: CreateNoteInput): Promise { const note = { id: `note-${Date.now()}`, diff --git a/apps/e2e/demo-e2e-public/src/apps/notes/tools/list-notes.tool.ts b/apps/e2e/demo-e2e-public/src/apps/notes/tools/list-notes.tool.ts index 225d3fdaa..19ee8668f 100644 --- a/apps/e2e/demo-e2e-public/src/apps/notes/tools/list-notes.tool.ts +++ b/apps/e2e/demo-e2e-public/src/apps/notes/tools/list-notes.tool.ts @@ -1,5 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import { notesStore } from '../data/notes.store'; const inputSchema = {}; @@ -25,7 +27,7 @@ type ListNotesOutput = z.infer; inputSchema, outputSchema, }) -export default class ListNotesTool extends ToolContext { +export default class ListNotesTool extends ToolContext { async execute(_input: ListNotesInput): Promise { const notes = notesStore.getAll(); return { diff --git a/apps/e2e/demo-e2e-public/src/apps/notes/tools/notes-reset.tool.ts b/apps/e2e/demo-e2e-public/src/apps/notes/tools/notes-reset.tool.ts index 97669e52f..ead646f29 100644 --- a/apps/e2e/demo-e2e-public/src/apps/notes/tools/notes-reset.tool.ts +++ b/apps/e2e/demo-e2e-public/src/apps/notes/tools/notes-reset.tool.ts @@ -1,5 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import { notesStore } from '../data/notes.store'; const inputSchema = {}; @@ -11,7 +13,7 @@ const outputSchema = z.object({ success: z.boolean() }); inputSchema, outputSchema, }) -export default class NotesResetTool extends ToolContext { +export default class NotesResetTool extends ToolContext { async execute(_input: z.infer>): Promise> { notesStore.clear(); return { success: true }; diff --git a/apps/e2e/demo-e2e-redis/src/apps/sessions/tools/get-session-data.tool.ts b/apps/e2e/demo-e2e-redis/src/apps/sessions/tools/get-session-data.tool.ts index 16bba7f4e..92f64da8b 100644 --- a/apps/e2e/demo-e2e-redis/src/apps/sessions/tools/get-session-data.tool.ts +++ b/apps/e2e/demo-e2e-redis/src/apps/sessions/tools/get-session-data.tool.ts @@ -1,7 +1,9 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; -import { getSessionStore } from '../data/session.store'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import { resolveDemoSessionId } from '../../resolve-session-id'; +import { getSessionStore } from '../data/session.store'; const inputSchema = { key: z.string().describe('Key to retrieve'), @@ -19,7 +21,7 @@ const outputSchema = z.object({ inputSchema, outputSchema, }) -export default class GetSessionDataTool extends ToolContext { +export default class GetSessionDataTool extends ToolContext { async execute(input: z.infer>): Promise> { // Prefer FrontMcpContext.sessionId (always available in public mode) over authInfo.sessionId const ctx = this.tryGetContext(); diff --git a/apps/e2e/demo-e2e-redis/src/apps/sessions/tools/set-session-data.tool.ts b/apps/e2e/demo-e2e-redis/src/apps/sessions/tools/set-session-data.tool.ts index ecaa9a926..c983bec7f 100644 --- a/apps/e2e/demo-e2e-redis/src/apps/sessions/tools/set-session-data.tool.ts +++ b/apps/e2e/demo-e2e-redis/src/apps/sessions/tools/set-session-data.tool.ts @@ -1,7 +1,9 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; -import { getSessionStore } from '../data/session.store'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import { resolveDemoSessionId } from '../../resolve-session-id'; +import { getSessionStore } from '../data/session.store'; const inputSchema = { key: z.string().describe('Key to store'), @@ -23,7 +25,7 @@ const outputSchema = z inputSchema, outputSchema, }) -export default class SetSessionDataTool extends ToolContext { +export default class SetSessionDataTool extends ToolContext { async execute(input: z.infer>): Promise> { // Prefer FrontMcpContext.sessionId (always available in public mode) over authInfo.sessionId const ctx = this.tryGetContext(); diff --git a/apps/e2e/demo-e2e-redis/src/apps/transport/tools/check-session.tool.ts b/apps/e2e/demo-e2e-redis/src/apps/transport/tools/check-session.tool.ts index 6bf894dd9..105661af1 100644 --- a/apps/e2e/demo-e2e-redis/src/apps/transport/tools/check-session.tool.ts +++ b/apps/e2e/demo-e2e-redis/src/apps/transport/tools/check-session.tool.ts @@ -1,7 +1,9 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; -import { getSessionStore } from '../../sessions/data/session.store'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import { resolveDemoSessionId } from '../../resolve-session-id'; +import { getSessionStore } from '../../sessions/data/session.store'; const inputSchema = { key: z.string().optional().describe('Specific state key to check'), @@ -24,7 +26,7 @@ const outputSchema = z inputSchema, outputSchema, }) -export default class CheckSessionTool extends ToolContext { +export default class CheckSessionTool extends ToolContext { async execute(input: z.infer>): Promise> { const ctx = this.tryGetContext(); const sessionId = resolveDemoSessionId(ctx?.sessionId, this.getAuthInfo().sessionId); diff --git a/apps/e2e/demo-e2e-redis/src/apps/transport/tools/session-info.tool.ts b/apps/e2e/demo-e2e-redis/src/apps/transport/tools/session-info.tool.ts index f87d96481..4e47cbbc7 100644 --- a/apps/e2e/demo-e2e-redis/src/apps/transport/tools/session-info.tool.ts +++ b/apps/e2e/demo-e2e-redis/src/apps/transport/tools/session-info.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = {}; const outputSchema = z @@ -23,7 +24,7 @@ const outputSchema = z inputSchema, outputSchema, }) -export default class SessionInfoTool extends ToolContext { +export default class SessionInfoTool extends ToolContext { async execute(_input: z.infer>): Promise> { const authInfo = this.getAuthInfo() as Record; const sessionId = authInfo.sessionId as string | undefined; diff --git a/apps/e2e/demo-e2e-redis/src/apps/transport/tools/session-isolation.tool.ts b/apps/e2e/demo-e2e-redis/src/apps/transport/tools/session-isolation.tool.ts index 1911ccaad..da307a18e 100644 --- a/apps/e2e/demo-e2e-redis/src/apps/transport/tools/session-isolation.tool.ts +++ b/apps/e2e/demo-e2e-redis/src/apps/transport/tools/session-isolation.tool.ts @@ -1,7 +1,9 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; -import { getSessionStore } from '../../sessions/data/session.store'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import { resolveDemoSessionId } from '../../resolve-session-id'; +import { getSessionStore } from '../../sessions/data/session.store'; const inputSchema = { action: z.enum(['set', 'get']).describe('Action to perform'), @@ -26,7 +28,7 @@ const outputSchema = z inputSchema, outputSchema, }) -export default class SessionIsolationTool extends ToolContext { +export default class SessionIsolationTool extends ToolContext { async execute(input: z.infer>): Promise> { const ctx = this.tryGetContext(); const sessionId = resolveDemoSessionId(ctx?.sessionId, this.getAuthInfo().sessionId); diff --git a/apps/e2e/demo-e2e-redis/src/apps/transport/tools/update-session-state.tool.ts b/apps/e2e/demo-e2e-redis/src/apps/transport/tools/update-session-state.tool.ts index b8a7814bc..518dd9f46 100644 --- a/apps/e2e/demo-e2e-redis/src/apps/transport/tools/update-session-state.tool.ts +++ b/apps/e2e/demo-e2e-redis/src/apps/transport/tools/update-session-state.tool.ts @@ -1,7 +1,9 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; -import { getSessionStore } from '../../sessions/data/session.store'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import { resolveDemoSessionId } from '../../resolve-session-id'; +import { getSessionStore } from '../../sessions/data/session.store'; const inputSchema = { key: z.string().describe('State key to update'), @@ -23,7 +25,7 @@ const outputSchema = z inputSchema, outputSchema, }) -export default class UpdateSessionStateTool extends ToolContext { +export default class UpdateSessionStateTool extends ToolContext { async execute(input: z.infer>): Promise> { const ctx = this.tryGetContext(); const sessionId = resolveDemoSessionId(ctx?.sessionId, this.getAuthInfo().sessionId); diff --git a/apps/e2e/demo-e2e-redis/src/apps/vault/tools/add-credential.tool.ts b/apps/e2e/demo-e2e-redis/src/apps/vault/tools/add-credential.tool.ts index 5e1cc6259..c40367725 100644 --- a/apps/e2e/demo-e2e-redis/src/apps/vault/tools/add-credential.tool.ts +++ b/apps/e2e/demo-e2e-redis/src/apps/vault/tools/add-credential.tool.ts @@ -1,8 +1,10 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; -import { getVault } from '../data/vault.store'; + import type { AppCredential, Credential } from '@frontmcp/auth'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + import { resolveDemoSessionId } from '../../resolve-session-id'; +import { getVault } from '../data/vault.store'; const inputSchema = { entryId: z.string().describe('Vault entry ID'), @@ -38,7 +40,7 @@ const outputSchema = z inputSchema, outputSchema, }) -export default class AddCredentialTool extends ToolContext { +export default class AddCredentialTool extends ToolContext { async execute(input: z.infer>): Promise> { const ctx = this.tryGetContext(); const sessionId = resolveDemoSessionId(ctx?.sessionId, this.getAuthInfo().sessionId); diff --git a/apps/e2e/demo-e2e-redis/src/apps/vault/tools/authorize-app.tool.ts b/apps/e2e/demo-e2e-redis/src/apps/vault/tools/authorize-app.tool.ts index 6e4673162..34d80413d 100644 --- a/apps/e2e/demo-e2e-redis/src/apps/vault/tools/authorize-app.tool.ts +++ b/apps/e2e/demo-e2e-redis/src/apps/vault/tools/authorize-app.tool.ts @@ -1,7 +1,9 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; -import { getVault } from '../data/vault.store'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import { resolveDemoSessionId } from '../../resolve-session-id'; +import { getVault } from '../data/vault.store'; const inputSchema = { entryId: z.string().describe('Vault entry ID'), @@ -22,7 +24,7 @@ const outputSchema = z inputSchema, outputSchema, }) -export default class AuthorizeAppTool extends ToolContext { +export default class AuthorizeAppTool extends ToolContext { async execute(input: z.infer>): Promise> { const ctx = this.tryGetContext(); const sessionId = resolveDemoSessionId(ctx?.sessionId, this.getAuthInfo().sessionId); diff --git a/apps/e2e/demo-e2e-redis/src/apps/vault/tools/complete-pending-auth.tool.ts b/apps/e2e/demo-e2e-redis/src/apps/vault/tools/complete-pending-auth.tool.ts index d42681682..1cfcd2e10 100644 --- a/apps/e2e/demo-e2e-redis/src/apps/vault/tools/complete-pending-auth.tool.ts +++ b/apps/e2e/demo-e2e-redis/src/apps/vault/tools/complete-pending-auth.tool.ts @@ -1,7 +1,9 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; -import { getVault } from '../data/vault.store'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import { resolveDemoSessionId } from '../../resolve-session-id'; +import { getVault } from '../data/vault.store'; const inputSchema = { entryId: z.string().describe('Vault entry ID'), @@ -22,7 +24,7 @@ const outputSchema = z inputSchema, outputSchema, }) -export default class CompletePendingAuthTool extends ToolContext { +export default class CompletePendingAuthTool extends ToolContext { async execute(input: z.infer>): Promise> { const ctx = this.tryGetContext(); const sessionId = resolveDemoSessionId(ctx?.sessionId, this.getAuthInfo().sessionId); diff --git a/apps/e2e/demo-e2e-redis/src/apps/vault/tools/create-pending-auth.tool.ts b/apps/e2e/demo-e2e-redis/src/apps/vault/tools/create-pending-auth.tool.ts index 6b5bd48d8..2da8d2f80 100644 --- a/apps/e2e/demo-e2e-redis/src/apps/vault/tools/create-pending-auth.tool.ts +++ b/apps/e2e/demo-e2e-redis/src/apps/vault/tools/create-pending-auth.tool.ts @@ -1,7 +1,9 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; -import { getVault } from '../data/vault.store'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import { resolveDemoSessionId } from '../../resolve-session-id'; +import { getVault } from '../data/vault.store'; const inputSchema = { entryId: z.string().describe('Vault entry ID'), @@ -27,7 +29,7 @@ const outputSchema = z inputSchema, outputSchema, }) -export default class CreatePendingAuthTool extends ToolContext { +export default class CreatePendingAuthTool extends ToolContext { async execute(input: z.infer>): Promise> { const ctx = this.tryGetContext(); const sessionId = resolveDemoSessionId(ctx?.sessionId, this.getAuthInfo().sessionId); diff --git a/apps/e2e/demo-e2e-redis/src/apps/vault/tools/create-vault-entry.tool.ts b/apps/e2e/demo-e2e-redis/src/apps/vault/tools/create-vault-entry.tool.ts index bb7f3fd98..ae23ffebf 100644 --- a/apps/e2e/demo-e2e-redis/src/apps/vault/tools/create-vault-entry.tool.ts +++ b/apps/e2e/demo-e2e-redis/src/apps/vault/tools/create-vault-entry.tool.ts @@ -1,7 +1,9 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; -import { getVault } from '../data/vault.store'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import { resolveDemoSessionId } from '../../resolve-session-id'; +import { getVault } from '../data/vault.store'; const inputSchema = { userSub: z.string().describe('User subject identifier'), @@ -26,7 +28,7 @@ const outputSchema = z inputSchema, outputSchema, }) -export default class CreateVaultEntryTool extends ToolContext { +export default class CreateVaultEntryTool extends ToolContext { async execute(input: z.infer>): Promise> { const ctx = this.tryGetContext(); const sessionId = resolveDemoSessionId(ctx?.sessionId, this.getAuthInfo().sessionId); diff --git a/apps/e2e/demo-e2e-redis/src/apps/vault/tools/delete-vault-entry.tool.ts b/apps/e2e/demo-e2e-redis/src/apps/vault/tools/delete-vault-entry.tool.ts index 55db617f5..dae0714e0 100644 --- a/apps/e2e/demo-e2e-redis/src/apps/vault/tools/delete-vault-entry.tool.ts +++ b/apps/e2e/demo-e2e-redis/src/apps/vault/tools/delete-vault-entry.tool.ts @@ -1,7 +1,9 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; -import { getVault } from '../data/vault.store'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import { resolveDemoSessionId } from '../../resolve-session-id'; +import { getVault } from '../data/vault.store'; const inputSchema = { entryId: z.string().describe('Vault entry ID to delete'), @@ -20,7 +22,7 @@ const outputSchema = z inputSchema, outputSchema, }) -export default class DeleteVaultEntryTool extends ToolContext { +export default class DeleteVaultEntryTool extends ToolContext { async execute(input: z.infer>): Promise> { const ctx = this.tryGetContext(); const sessionId = resolveDemoSessionId(ctx?.sessionId, this.getAuthInfo().sessionId); diff --git a/apps/e2e/demo-e2e-redis/src/apps/vault/tools/get-vault-entry.tool.ts b/apps/e2e/demo-e2e-redis/src/apps/vault/tools/get-vault-entry.tool.ts index 8b6dfa67c..167fdbdd1 100644 --- a/apps/e2e/demo-e2e-redis/src/apps/vault/tools/get-vault-entry.tool.ts +++ b/apps/e2e/demo-e2e-redis/src/apps/vault/tools/get-vault-entry.tool.ts @@ -1,7 +1,9 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; -import { getVault } from '../data/vault.store'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import { resolveDemoSessionId } from '../../resolve-session-id'; +import { getVault } from '../data/vault.store'; const inputSchema = { entryId: z.string().describe('Vault entry ID to retrieve'), @@ -36,7 +38,7 @@ const outputSchema = z inputSchema, outputSchema, }) -export default class GetVaultEntryTool extends ToolContext { +export default class GetVaultEntryTool extends ToolContext { async execute(input: z.infer>): Promise> { const ctx = this.tryGetContext(); const sessionId = resolveDemoSessionId(ctx?.sessionId, this.getAuthInfo().sessionId); diff --git a/apps/e2e/demo-e2e-redis/src/apps/vault/tools/update-consent.tool.ts b/apps/e2e/demo-e2e-redis/src/apps/vault/tools/update-consent.tool.ts index e1578d349..2483a7e32 100644 --- a/apps/e2e/demo-e2e-redis/src/apps/vault/tools/update-consent.tool.ts +++ b/apps/e2e/demo-e2e-redis/src/apps/vault/tools/update-consent.tool.ts @@ -1,8 +1,10 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; -import { getVault } from '../data/vault.store'; + import type { VaultConsentRecord } from '@frontmcp/auth'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + import { resolveDemoSessionId } from '../../resolve-session-id'; +import { getVault } from '../data/vault.store'; const inputSchema = { entryId: z.string().describe('Vault entry ID'), @@ -24,7 +26,7 @@ const outputSchema = z inputSchema, outputSchema, }) -export default class UpdateConsentTool extends ToolContext { +export default class UpdateConsentTool extends ToolContext { async execute(input: z.infer>): Promise> { const ctx = this.tryGetContext(); const sessionId = resolveDemoSessionId(ctx?.sessionId, this.getAuthInfo().sessionId); diff --git a/apps/e2e/demo-e2e-redis/src/apps/vault/tools/update-vault-entry.tool.ts b/apps/e2e/demo-e2e-redis/src/apps/vault/tools/update-vault-entry.tool.ts index d3ab3802d..d3884393a 100644 --- a/apps/e2e/demo-e2e-redis/src/apps/vault/tools/update-vault-entry.tool.ts +++ b/apps/e2e/demo-e2e-redis/src/apps/vault/tools/update-vault-entry.tool.ts @@ -1,7 +1,9 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; -import { getVault } from '../data/vault.store'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import { resolveDemoSessionId } from '../../resolve-session-id'; +import { getVault } from '../data/vault.store'; const inputSchema = { entryId: z.string().describe('Vault entry ID to update'), @@ -22,7 +24,7 @@ const outputSchema = z inputSchema, outputSchema, }) -export default class UpdateVaultEntryTool extends ToolContext { +export default class UpdateVaultEntryTool extends ToolContext { async execute(input: z.infer>): Promise> { const ctx = this.tryGetContext(); const sessionId = resolveDemoSessionId(ctx?.sessionId, this.getAuthInfo().sessionId); diff --git a/apps/e2e/demo-e2e-remember/src/apps/memory/tools/check-memory.tool.ts b/apps/e2e/demo-e2e-remember/src/apps/memory/tools/check-memory.tool.ts index 67b44b349..574f05cb6 100644 --- a/apps/e2e/demo-e2e-remember/src/apps/memory/tools/check-memory.tool.ts +++ b/apps/e2e/demo-e2e-remember/src/apps/memory/tools/check-memory.tool.ts @@ -1,5 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import '@frontmcp/plugin-remember'; // Import for this.remember types const inputSchema = { @@ -22,7 +24,7 @@ type Output = z.infer; inputSchema, outputSchema, }) -export default class CheckMemoryTool extends ToolContext { +export default class CheckMemoryTool extends ToolContext { async execute(input: Input): Promise { const exists = await this.remember.knows(input.key, { scope: input.scope, diff --git a/apps/e2e/demo-e2e-remember/src/apps/memory/tools/forget-value.tool.ts b/apps/e2e/demo-e2e-remember/src/apps/memory/tools/forget-value.tool.ts index fa970eda3..963764815 100644 --- a/apps/e2e/demo-e2e-remember/src/apps/memory/tools/forget-value.tool.ts +++ b/apps/e2e/demo-e2e-remember/src/apps/memory/tools/forget-value.tool.ts @@ -1,5 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import '@frontmcp/plugin-remember'; // Import for this.remember types const inputSchema = { @@ -27,7 +29,7 @@ type Output = z.infer; inputSchema, outputSchema, }) -export default class ForgetValueTool extends ToolContext { +export default class ForgetValueTool extends ToolContext { async execute(input: Input): Promise { await this.remember.forget(input.key, { scope: input.scope, diff --git a/apps/e2e/demo-e2e-remember/src/apps/memory/tools/list-memories.tool.ts b/apps/e2e/demo-e2e-remember/src/apps/memory/tools/list-memories.tool.ts index 3665ff75e..3e9b0ea63 100644 --- a/apps/e2e/demo-e2e-remember/src/apps/memory/tools/list-memories.tool.ts +++ b/apps/e2e/demo-e2e-remember/src/apps/memory/tools/list-memories.tool.ts @@ -1,5 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import '@frontmcp/plugin-remember'; // Import for this.remember types const inputSchema = { @@ -26,7 +28,7 @@ type Output = z.infer; inputSchema, outputSchema, }) -export default class ListMemoriesTool extends ToolContext { +export default class ListMemoriesTool extends ToolContext { async execute(input: Input): Promise { const keys = await this.remember.list({ scope: input.scope, diff --git a/apps/e2e/demo-e2e-remember/src/apps/memory/tools/recall-value.tool.ts b/apps/e2e/demo-e2e-remember/src/apps/memory/tools/recall-value.tool.ts index 3938c26b9..3e185bffc 100644 --- a/apps/e2e/demo-e2e-remember/src/apps/memory/tools/recall-value.tool.ts +++ b/apps/e2e/demo-e2e-remember/src/apps/memory/tools/recall-value.tool.ts @@ -1,5 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import '@frontmcp/plugin-remember'; // Import for this.remember types const inputSchema = { @@ -27,7 +29,7 @@ type Output = z.infer; inputSchema, outputSchema, }) -export default class RecallValueTool extends ToolContext { +export default class RecallValueTool extends ToolContext { async execute(input: Input): Promise { const value = await this.remember.get(input.key, { scope: input.scope, diff --git a/apps/e2e/demo-e2e-remember/src/apps/memory/tools/remember-value.tool.ts b/apps/e2e/demo-e2e-remember/src/apps/memory/tools/remember-value.tool.ts index f1c865792..abddf74f8 100644 --- a/apps/e2e/demo-e2e-remember/src/apps/memory/tools/remember-value.tool.ts +++ b/apps/e2e/demo-e2e-remember/src/apps/memory/tools/remember-value.tool.ts @@ -1,5 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import '@frontmcp/plugin-remember'; // Import for this.remember types const inputSchema = { @@ -25,7 +27,7 @@ type Output = z.infer; inputSchema, outputSchema, }) -export default class RememberValueTool extends ToolContext { +export default class RememberValueTool extends ToolContext { async execute(input: Input): Promise { await this.remember.set(input.key, input.value, { scope: input.scope, diff --git a/apps/e2e/demo-e2e-remote/src/local-mcp-server/apps/local-test/tools/add.tool.ts b/apps/e2e/demo-e2e-remote/src/local-mcp-server/apps/local-test/tools/add.tool.ts index 14fcbedd0..5683a9038 100644 --- a/apps/e2e/demo-e2e-remote/src/local-mcp-server/apps/local-test/tools/add.tool.ts +++ b/apps/e2e/demo-e2e-remote/src/local-mcp-server/apps/local-test/tools/add.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = { a: z.number().describe('First number to add'), b: z.number().describe('Second number to add'), @@ -20,7 +21,7 @@ type AddOutput = z.infer; inputSchema, outputSchema, }) -export default class AddTool extends ToolContext { +export default class AddTool extends ToolContext { async execute(input: AddInput): Promise { return { result: input.a + input.b, diff --git a/apps/e2e/demo-e2e-remote/src/local-mcp-server/apps/local-test/tools/echo.tool.ts b/apps/e2e/demo-e2e-remote/src/local-mcp-server/apps/local-test/tools/echo.tool.ts index b7b06bd40..9a7a763a2 100644 --- a/apps/e2e/demo-e2e-remote/src/local-mcp-server/apps/local-test/tools/echo.tool.ts +++ b/apps/e2e/demo-e2e-remote/src/local-mcp-server/apps/local-test/tools/echo.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = { message: z.string().describe('The message to echo back'), }; @@ -19,7 +20,7 @@ type EchoOutput = z.infer; inputSchema, outputSchema, }) -export default class EchoTool extends ToolContext { +export default class EchoTool extends ToolContext { async execute(input: EchoInput): Promise { return { echo: input.message, diff --git a/apps/e2e/demo-e2e-remote/src/local-mcp-server/apps/local-test/tools/ping.tool.ts b/apps/e2e/demo-e2e-remote/src/local-mcp-server/apps/local-test/tools/ping.tool.ts index d7308dc4d..5d2aff749 100644 --- a/apps/e2e/demo-e2e-remote/src/local-mcp-server/apps/local-test/tools/ping.tool.ts +++ b/apps/e2e/demo-e2e-remote/src/local-mcp-server/apps/local-test/tools/ping.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = {}; const outputSchema = z.object({ @@ -18,7 +19,7 @@ type PingOutput = z.infer; inputSchema, outputSchema, }) -export default class PingTool extends ToolContext { +export default class PingTool extends ToolContext { async execute(_input: PingInput): Promise { return { pong: true, diff --git a/apps/e2e/demo-e2e-remote/src/local-mcp-server/apps/local-test/tools/slow-operation.tool.ts b/apps/e2e/demo-e2e-remote/src/local-mcp-server/apps/local-test/tools/slow-operation.tool.ts index 571086711..2ff758cbb 100644 --- a/apps/e2e/demo-e2e-remote/src/local-mcp-server/apps/local-test/tools/slow-operation.tool.ts +++ b/apps/e2e/demo-e2e-remote/src/local-mcp-server/apps/local-test/tools/slow-operation.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = { delayMs: z.number().min(0).max(100000).describe('Delay in milliseconds (max ~100s to fit E2E timeout)'), }; @@ -21,7 +22,7 @@ type SlowOperationOutput = z.infer; inputSchema, outputSchema, }) -export default class SlowOperationTool extends ToolContext { +export default class SlowOperationTool extends ToolContext { async execute(input: SlowOperationInput): Promise { const startedAt = new Date(); diff --git a/apps/e2e/demo-e2e-remote/src/mock-mintlify-server/apps/mock-mintlify/tools/search-mintlify.tool.ts b/apps/e2e/demo-e2e-remote/src/mock-mintlify-server/apps/mock-mintlify/tools/search-mintlify.tool.ts index ced750043..0ab928958 100644 --- a/apps/e2e/demo-e2e-remote/src/mock-mintlify-server/apps/mock-mintlify/tools/search-mintlify.tool.ts +++ b/apps/e2e/demo-e2e-remote/src/mock-mintlify-server/apps/mock-mintlify/tools/search-mintlify.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = { query: z.string().describe('Search query for Mintlify documentation'), }; @@ -58,7 +59,7 @@ const MOCK_DOCS = [ inputSchema, outputSchema, }) -export default class SearchMintlifyTool extends ToolContext { +export default class SearchMintlifyTool extends ToolContext { async execute(input: SearchInput): Promise { const query = input.query.toLowerCase(); diff --git a/apps/e2e/demo-e2e-serverless/src/apps/serverless/tools/cold-start-test.tool.ts b/apps/e2e/demo-e2e-serverless/src/apps/serverless/tools/cold-start-test.tool.ts index 5eb299102..3b5a6aa7c 100644 --- a/apps/e2e/demo-e2e-serverless/src/apps/serverless/tools/cold-start-test.tool.ts +++ b/apps/e2e/demo-e2e-serverless/src/apps/serverless/tools/cold-start-test.tool.ts @@ -1,5 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import { deploymentTracker } from '../data/deployment-tracker'; const inputSchema = { @@ -31,7 +33,7 @@ type Output = z.infer; inputSchema, outputSchema, }) -export default class ColdStartTestTool extends ToolContext { +export default class ColdStartTestTool extends ToolContext { async execute(input: Input): Promise { const tracker = deploymentTracker; const start = Date.now(); diff --git a/apps/e2e/demo-e2e-serverless/src/apps/serverless/tools/serverless-info.tool.ts b/apps/e2e/demo-e2e-serverless/src/apps/serverless/tools/serverless-info.tool.ts index e23cd2be6..dcda02abc 100644 --- a/apps/e2e/demo-e2e-serverless/src/apps/serverless/tools/serverless-info.tool.ts +++ b/apps/e2e/demo-e2e-serverless/src/apps/serverless/tools/serverless-info.tool.ts @@ -1,5 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import { deploymentTracker } from '../data/deployment-tracker'; const inputSchema = {}; @@ -30,7 +32,7 @@ type Output = z.infer; inputSchema, outputSchema, }) -export default class ServerlessInfoTool extends ToolContext { +export default class ServerlessInfoTool extends ToolContext { async execute(_input: Input): Promise { const tracker = deploymentTracker; const start = Date.now(); diff --git a/apps/e2e/demo-e2e-skills/src/apps/skills/plugins/devops-plugin.ts b/apps/e2e/demo-e2e-skills/src/apps/skills/plugins/devops-plugin.ts index 30354ee52..d3ec2809c 100644 --- a/apps/e2e/demo-e2e-skills/src/apps/skills/plugins/devops-plugin.ts +++ b/apps/e2e/demo-e2e-skills/src/apps/skills/plugins/devops-plugin.ts @@ -1,6 +1,7 @@ -import { Plugin, Skill, Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Plugin, Skill, Tool, ToolContext } from '@frontmcp/sdk'; + // ============================================================================= // Plugin Tools // ============================================================================= @@ -27,7 +28,7 @@ type DeployOutput = z.infer>; outputSchema: deployOutputSchema, tags: ['devops', 'deployment'], }) -class DeployTool extends ToolContext { +class DeployTool extends ToolContext { async execute(input: DeployInput): Promise { return { success: true, diff --git a/apps/e2e/demo-e2e-skills/src/apps/skills/tools/admin-action.tool.ts b/apps/e2e/demo-e2e-skills/src/apps/skills/tools/admin-action.tool.ts index 3b5020c1f..3662d6340 100644 --- a/apps/e2e/demo-e2e-skills/src/apps/skills/tools/admin-action.tool.ts +++ b/apps/e2e/demo-e2e-skills/src/apps/skills/tools/admin-action.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = { action: z.string().describe('The admin action to perform'), }; @@ -24,7 +25,7 @@ type Output = z.infer>; outputSchema, tags: ['admin', 'test'], }) -export class AdminActionTool extends ToolContext { +export class AdminActionTool extends ToolContext { async execute(input: Input): Promise { // Mock implementation for testing return { diff --git a/apps/e2e/demo-e2e-skills/src/apps/skills/tools/github-add-comment.tool.ts b/apps/e2e/demo-e2e-skills/src/apps/skills/tools/github-add-comment.tool.ts index bc73e9ee0..3f2269b1d 100644 --- a/apps/e2e/demo-e2e-skills/src/apps/skills/tools/github-add-comment.tool.ts +++ b/apps/e2e/demo-e2e-skills/src/apps/skills/tools/github-add-comment.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = { prNumber: z.number().describe('The pull request number'), comment: z.string().describe('The comment text to add'), @@ -21,7 +22,7 @@ type Output = z.infer>; outputSchema, tags: ['github', 'pr', 'comment'], }) -export class GitHubAddCommentTool extends ToolContext { +export class GitHubAddCommentTool extends ToolContext { async execute(input: Input): Promise { // Mock implementation for testing return { diff --git a/apps/e2e/demo-e2e-skills/src/apps/skills/tools/github-get-pr.tool.ts b/apps/e2e/demo-e2e-skills/src/apps/skills/tools/github-get-pr.tool.ts index ab4eeac71..d55ed2f29 100644 --- a/apps/e2e/demo-e2e-skills/src/apps/skills/tools/github-get-pr.tool.ts +++ b/apps/e2e/demo-e2e-skills/src/apps/skills/tools/github-get-pr.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = { prNumber: z.number().describe('The pull request number'), }; @@ -25,7 +26,7 @@ type Output = z.infer>; outputSchema, tags: ['github', 'pr'], }) -export class GitHubGetPRTool extends ToolContext { +export class GitHubGetPRTool extends ToolContext { async execute(input: Input): Promise { // Mock implementation for testing return { diff --git a/apps/e2e/demo-e2e-skills/src/apps/skills/tools/slack-notify.tool.ts b/apps/e2e/demo-e2e-skills/src/apps/skills/tools/slack-notify.tool.ts index 564bf15f8..bd64497a8 100644 --- a/apps/e2e/demo-e2e-skills/src/apps/skills/tools/slack-notify.tool.ts +++ b/apps/e2e/demo-e2e-skills/src/apps/skills/tools/slack-notify.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = { channel: z.string().describe('The Slack channel to send the message to'), message: z.string().describe('The message text to send'), @@ -22,7 +23,7 @@ type Output = z.infer>; outputSchema, tags: ['slack', 'notification'], }) -export class SlackNotifyTool extends ToolContext { +export class SlackNotifyTool extends ToolContext { async execute(input: Input): Promise { // Mock implementation for testing return { diff --git a/apps/e2e/demo-e2e-stdio-transport/src/apps/notes/tools/create-note.tool.ts b/apps/e2e/demo-e2e-stdio-transport/src/apps/notes/tools/create-note.tool.ts index d75dceb4f..c0eaa9678 100644 --- a/apps/e2e/demo-e2e-stdio-transport/src/apps/notes/tools/create-note.tool.ts +++ b/apps/e2e/demo-e2e-stdio-transport/src/apps/notes/tools/create-note.tool.ts @@ -1,6 +1,8 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; import { randomUUID } from '@frontmcp/utils'; + import { notesStore } from '../data/notes.store'; const inputSchema = { @@ -24,7 +26,7 @@ type CreateNoteOutput = z.infer; inputSchema, outputSchema, }) -export default class CreateNoteTool extends ToolContext { +export default class CreateNoteTool extends ToolContext { async execute(input: CreateNoteInput): Promise { const note = { id: `note-${randomUUID()}`, diff --git a/apps/e2e/demo-e2e-stdio-transport/src/apps/notes/tools/list-notes.tool.ts b/apps/e2e/demo-e2e-stdio-transport/src/apps/notes/tools/list-notes.tool.ts index 225d3fdaa..19ee8668f 100644 --- a/apps/e2e/demo-e2e-stdio-transport/src/apps/notes/tools/list-notes.tool.ts +++ b/apps/e2e/demo-e2e-stdio-transport/src/apps/notes/tools/list-notes.tool.ts @@ -1,5 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import { notesStore } from '../data/notes.store'; const inputSchema = {}; @@ -25,7 +27,7 @@ type ListNotesOutput = z.infer; inputSchema, outputSchema, }) -export default class ListNotesTool extends ToolContext { +export default class ListNotesTool extends ToolContext { async execute(_input: ListNotesInput): Promise { const notes = notesStore.getAll(); return { diff --git a/apps/e2e/demo-e2e-transparent/src/apps/tasks/tools/create-task.tool.ts b/apps/e2e/demo-e2e-transparent/src/apps/tasks/tools/create-task.tool.ts index d328b384c..662820d1f 100644 --- a/apps/e2e/demo-e2e-transparent/src/apps/tasks/tools/create-task.tool.ts +++ b/apps/e2e/demo-e2e-transparent/src/apps/tasks/tools/create-task.tool.ts @@ -1,5 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import { tasksStore } from '../data/tasks.store'; const inputSchema = { @@ -27,7 +29,7 @@ type CreateTaskOutput = z.infer; inputSchema, outputSchema, }) -export default class CreateTaskTool extends ToolContext { +export default class CreateTaskTool extends ToolContext { async execute(input: CreateTaskInput): Promise { const authInfo = this.getAuthInfo(); const userId = authInfo?.user?.sub || 'anonymous'; diff --git a/apps/e2e/demo-e2e-transparent/src/apps/tasks/tools/list-tasks.tool.ts b/apps/e2e/demo-e2e-transparent/src/apps/tasks/tools/list-tasks.tool.ts index 599255559..f56eda458 100644 --- a/apps/e2e/demo-e2e-transparent/src/apps/tasks/tools/list-tasks.tool.ts +++ b/apps/e2e/demo-e2e-transparent/src/apps/tasks/tools/list-tasks.tool.ts @@ -1,5 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import { tasksStore } from '../data/tasks.store'; const inputSchema = { @@ -30,7 +32,7 @@ type ListTasksOutput = z.infer; inputSchema, outputSchema, }) -export default class ListTasksTool extends ToolContext { +export default class ListTasksTool extends ToolContext { async execute(input: ListTasksInput): Promise { const authInfo = this.getAuthInfo(); const userId = authInfo?.user?.sub || 'anonymous'; diff --git a/apps/e2e/demo-e2e-transport-recreation/src/apps/transport-test/tools/increment-counter.tool.ts b/apps/e2e/demo-e2e-transport-recreation/src/apps/transport-test/tools/increment-counter.tool.ts index e4831bd14..5ae4965ea 100644 --- a/apps/e2e/demo-e2e-transport-recreation/src/apps/transport-test/tools/increment-counter.tool.ts +++ b/apps/e2e/demo-e2e-transport-recreation/src/apps/transport-test/tools/increment-counter.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = { amount: z.number().int().min(1).default(1).describe('Amount to increment by'), }; @@ -21,7 +22,7 @@ const sessionCounters = new Map(); inputSchema, outputSchema, }) -export default class IncrementCounterTool extends ToolContext { +export default class IncrementCounterTool extends ToolContext { async execute(input: z.infer>): Promise> { // Prefer FrontMcpContext.sessionId (set when context exists) over authInfo.sessionId (may be undefined in public mode) const ctx = this.tryGetContext(); diff --git a/apps/e2e/demo-e2e-ui/src/apps/widgets/tools/html-card.tool.ts b/apps/e2e/demo-e2e-ui/src/apps/widgets/tools/html-card.tool.ts index 4aacd6ae2..e1d29d754 100644 --- a/apps/e2e/demo-e2e-ui/src/apps/widgets/tools/html-card.tool.ts +++ b/apps/e2e/demo-e2e-ui/src/apps/widgets/tools/html-card.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + /** * Escape HTML special characters to prevent XSS */ @@ -59,7 +60,7 @@ type Output = z.infer; }, }, }) -export default class HtmlCardTool extends ToolContext { +export default class HtmlCardTool extends ToolContext { async execute(input: Input): Promise { return { uiType: 'html', diff --git a/apps/e2e/demo-e2e-ui/src/apps/widgets/tools/html-table.tool.ts b/apps/e2e/demo-e2e-ui/src/apps/widgets/tools/html-table.tool.ts index 4f9cd7097..335d7545d 100644 --- a/apps/e2e/demo-e2e-ui/src/apps/widgets/tools/html-table.tool.ts +++ b/apps/e2e/demo-e2e-ui/src/apps/widgets/tools/html-table.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = { headers: z.array(z.string()).describe('Table column headers'), rows: z.array(z.array(z.string())).describe('Table row data'), @@ -56,7 +57,7 @@ type Output = z.infer; }, }, }) -export default class HtmlTableTool extends ToolContext { +export default class HtmlTableTool extends ToolContext { async execute(input: Input): Promise { return { uiType: 'html', diff --git a/apps/e2e/demo-e2e-ui/src/apps/widgets/tools/hybrid-status.tool.ts b/apps/e2e/demo-e2e-ui/src/apps/widgets/tools/hybrid-status.tool.ts index 20cf6ecfe..54c1f8a77 100644 --- a/apps/e2e/demo-e2e-ui/src/apps/widgets/tools/hybrid-status.tool.ts +++ b/apps/e2e/demo-e2e-ui/src/apps/widgets/tools/hybrid-status.tool.ts @@ -5,9 +5,10 @@ * Supported by: OpenAI, ext-apps, Cursor * NOT supported by: Claude, Continue, Cody, generic-mcp (will skip UI) */ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = { serviceName: z.string().describe('Name of the service'), status: z.enum(['healthy', 'degraded', 'down']).describe('Service status'), @@ -82,7 +83,7 @@ type Output = z.infer; }, }, }) -export default class HybridStatusTool extends ToolContext { +export default class HybridStatusTool extends ToolContext { async execute(input: Input): Promise { const statusColors: Record = { healthy: 'green', diff --git a/apps/e2e/demo-e2e-ui/src/apps/widgets/tools/markdown-list.tool.ts b/apps/e2e/demo-e2e-ui/src/apps/widgets/tools/markdown-list.tool.ts index 5ff49ebf6..c60382b04 100644 --- a/apps/e2e/demo-e2e-ui/src/apps/widgets/tools/markdown-list.tool.ts +++ b/apps/e2e/demo-e2e-ui/src/apps/widgets/tools/markdown-list.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = { title: z.string().describe('List title'), items: z @@ -47,7 +48,7 @@ ${markdown} }, }, }) -export default class MarkdownListTool extends ToolContext { +export default class MarkdownListTool extends ToolContext { async execute(input: Input): Promise { const completedCount = input.items.filter((i) => i.completed).length; diff --git a/apps/e2e/demo-e2e-ui/src/apps/widgets/tools/markdown-report.tool.ts b/apps/e2e/demo-e2e-ui/src/apps/widgets/tools/markdown-report.tool.ts index 068f3ef0a..69c32c306 100644 --- a/apps/e2e/demo-e2e-ui/src/apps/widgets/tools/markdown-report.tool.ts +++ b/apps/e2e/demo-e2e-ui/src/apps/widgets/tools/markdown-report.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = { title: z.string().describe('Report title'), summary: z.string().describe('Executive summary'), @@ -53,7 +54,7 @@ ${markdown} }, }, }) -export default class MarkdownReportTool extends ToolContext { +export default class MarkdownReportTool extends ToolContext { async execute(input: Input): Promise { const severityEmoji = { low: '🟢', diff --git a/apps/e2e/demo-e2e-ui/src/apps/widgets/tools/mdx-doc.tool.ts b/apps/e2e/demo-e2e-ui/src/apps/widgets/tools/mdx-doc.tool.ts index 76be1de1c..e503ad643 100644 --- a/apps/e2e/demo-e2e-ui/src/apps/widgets/tools/mdx-doc.tool.ts +++ b/apps/e2e/demo-e2e-ui/src/apps/widgets/tools/mdx-doc.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = { title: z.string().describe('Document title'), sections: z @@ -51,7 +52,7 @@ ${mdxContent} }, }, }) -export default class MdxDocTool extends ToolContext { +export default class MdxDocTool extends ToolContext { async execute(input: Input): Promise { const mdxContent = input.sections.map((section) => `## ${section.heading}\n\n${section.content}`).join('\n\n'); diff --git a/apps/e2e/demo-e2e-ui/src/apps/widgets/tools/mdx-interactive.tool.ts b/apps/e2e/demo-e2e-ui/src/apps/widgets/tools/mdx-interactive.tool.ts index 0f6884d02..4888a3696 100644 --- a/apps/e2e/demo-e2e-ui/src/apps/widgets/tools/mdx-interactive.tool.ts +++ b/apps/e2e/demo-e2e-ui/src/apps/widgets/tools/mdx-interactive.tool.ts @@ -1,7 +1,8 @@ import React from 'react'; -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = { topic: z.string().describe('Interactive topic'), points: z.array(z.string()).describe('Key points to display'), @@ -131,7 +132,7 @@ ${codeExample} }, }, }) -export default class MdxInteractiveTool extends ToolContext { +export default class MdxInteractiveTool extends ToolContext { async execute(input: Input): Promise { return { uiType: 'mdx', diff --git a/apps/e2e/demo-e2e-ui/src/apps/widgets/tools/react-chart.tool.ts b/apps/e2e/demo-e2e-ui/src/apps/widgets/tools/react-chart.tool.ts index 9255b2bb8..5f1d0f77e 100644 --- a/apps/e2e/demo-e2e-ui/src/apps/widgets/tools/react-chart.tool.ts +++ b/apps/e2e/demo-e2e-ui/src/apps/widgets/tools/react-chart.tool.ts @@ -5,8 +5,10 @@ * The UI is defined as a React component and rendered via the React renderer. */ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import ChartCard from './chart-ui'; // Define input/output schemas @@ -54,7 +56,7 @@ type ChartOutput = z.infer; resourceMode: 'cdn', }, }) -export default class ReactChartTool extends ToolContext { +export default class ReactChartTool extends ToolContext { async execute(input: ChartInput): Promise { const maxValue = Math.max(...input.data.map((d) => d.value), 1); diff --git a/apps/e2e/demo-e2e-ui/src/apps/widgets/tools/react-form.tool.ts b/apps/e2e/demo-e2e-ui/src/apps/widgets/tools/react-form.tool.ts index 0efe7aa57..5aff2fe42 100644 --- a/apps/e2e/demo-e2e-ui/src/apps/widgets/tools/react-form.tool.ts +++ b/apps/e2e/demo-e2e-ui/src/apps/widgets/tools/react-form.tool.ts @@ -5,8 +5,10 @@ * The UI is defined as a React component and rendered via the React renderer. */ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import FormCard from './form-ui'; // Define input/output schemas @@ -59,7 +61,7 @@ type FormOutput = z.infer; resourceMode: 'cdn', }, }) -export default class ReactFormTool extends ToolContext { +export default class ReactFormTool extends ToolContext { async execute(input: FormInput): Promise { return { fields: input.fields, diff --git a/apps/e2e/demo-e2e-ui/src/apps/widgets/tools/react-weather.tool.ts b/apps/e2e/demo-e2e-ui/src/apps/widgets/tools/react-weather.tool.ts index 35e588d2c..c25543fa4 100644 --- a/apps/e2e/demo-e2e-ui/src/apps/widgets/tools/react-weather.tool.ts +++ b/apps/e2e/demo-e2e-ui/src/apps/widgets/tools/react-weather.tool.ts @@ -4,9 +4,10 @@ * Uses `template: { file: '...' }` which bundles the React component at * server startup via esbuild, producing inline HTML with esm.sh import maps. */ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = { location: z.string().describe('City name'), }; @@ -35,7 +36,7 @@ type Output = z.infer; template: { file: 'apps/e2e/demo-e2e-ui/src/apps/widgets/tools/react-weather.ui.tsx' }, }, }) -export default class ReactWeatherTool extends ToolContext { +export default class ReactWeatherTool extends ToolContext { async execute(input: Input): Promise { const data: Record> = { london: { temperature: 14, conditions: 'rainy', icon: 'rainy' }, diff --git a/apps/e2e/demo-e2e-ui/src/apps/widgets/tools/static-badge.tool.ts b/apps/e2e/demo-e2e-ui/src/apps/widgets/tools/static-badge.tool.ts index 079963db2..5c13d08b1 100644 --- a/apps/e2e/demo-e2e-ui/src/apps/widgets/tools/static-badge.tool.ts +++ b/apps/e2e/demo-e2e-ui/src/apps/widgets/tools/static-badge.tool.ts @@ -5,9 +5,10 @@ * Supported by: OpenAI, ext-apps, Cursor, generic-mcp * NOT supported by: Claude, Continue, Cody (will skip UI) */ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = { label: z.string().describe('Badge label text'), value: z.string().describe('Badge value text'), @@ -51,7 +52,7 @@ type Output = z.infer; `.trim(), }, }) -export default class StaticBadgeTool extends ToolContext { +export default class StaticBadgeTool extends ToolContext { async execute(input: Input): Promise { return { label: input.label, diff --git a/apps/e2e/demo-e2e-uipack/src/apps/uipack/tools/build-shell.tool.ts b/apps/e2e/demo-e2e-uipack/src/apps/uipack/tools/build-shell.tool.ts index 2aeee8444..dbe696d8d 100644 --- a/apps/e2e/demo-e2e-uipack/src/apps/uipack/tools/build-shell.tool.ts +++ b/apps/e2e/demo-e2e-uipack/src/apps/uipack/tools/build-shell.tool.ts @@ -1,5 +1,6 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; import { buildShell } from '@frontmcp/uipack'; const inputSchema = { @@ -28,7 +29,7 @@ type Output = z.infer; inputSchema, outputSchema, }) -export default class BuildShellTool extends ToolContext { +export default class BuildShellTool extends ToolContext { async execute(input: Input): Promise { const result = buildShell(input.content, { toolName: input.toolName, diff --git a/apps/e2e/demo-e2e-uipack/src/apps/uipack/tools/fetch-types.tool.ts b/apps/e2e/demo-e2e-uipack/src/apps/uipack/tools/fetch-types.tool.ts index 4dda68392..1bd8a898e 100644 --- a/apps/e2e/demo-e2e-uipack/src/apps/uipack/tools/fetch-types.tool.ts +++ b/apps/e2e/demo-e2e-uipack/src/apps/uipack/tools/fetch-types.tool.ts @@ -1,5 +1,6 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; import { createTypeFetcher } from '@frontmcp/uipack'; const inputSchema = { @@ -44,7 +45,7 @@ type Output = z.infer; inputSchema, outputSchema, }) -export default class FetchTypesTool extends ToolContext { +export default class FetchTypesTool extends ToolContext { async execute(input: Input): Promise { const fetcher = createTypeFetcher({ maxDepth: input.maxDepth, diff --git a/apps/e2e/demo-e2e-uipack/src/apps/uipack/tools/load-component.tool.ts b/apps/e2e/demo-e2e-uipack/src/apps/uipack/tools/load-component.tool.ts index 9d3fd448c..c8619a162 100644 --- a/apps/e2e/demo-e2e-uipack/src/apps/uipack/tools/load-component.tool.ts +++ b/apps/e2e/demo-e2e-uipack/src/apps/uipack/tools/load-component.tool.ts @@ -1,7 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; -import { resolveUISource } from '@frontmcp/uipack'; -import type { UISource } from '@frontmcp/uipack'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; +import { resolveUISource, type UISource } from '@frontmcp/uipack'; const inputSchema = { sourceType: z.enum(['npm', 'import', 'function']).describe('Type of UI source'), @@ -27,7 +27,7 @@ type Output = z.infer; inputSchema, outputSchema, }) -export default class LoadComponentTool extends ToolContext { +export default class LoadComponentTool extends ToolContext { async execute(input: Input): Promise { let source: UISource; diff --git a/apps/e2e/demo-e2e-uipack/src/apps/uipack/tools/resolve-imports.tool.ts b/apps/e2e/demo-e2e-uipack/src/apps/uipack/tools/resolve-imports.tool.ts index 8c8fa9229..5711b16e1 100644 --- a/apps/e2e/demo-e2e-uipack/src/apps/uipack/tools/resolve-imports.tool.ts +++ b/apps/e2e/demo-e2e-uipack/src/apps/uipack/tools/resolve-imports.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; -import { rewriteImports, createEsmShResolver } from '@frontmcp/uipack'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; +import { createEsmShResolver, rewriteImports } from '@frontmcp/uipack'; const inputSchema = { source: z.string().describe('Source code containing import statements'), @@ -22,7 +23,7 @@ type Output = z.infer; inputSchema, outputSchema, }) -export default class ResolveImportsTool extends ToolContext { +export default class ResolveImportsTool extends ToolContext { async execute(input: Input): Promise { const resolver = createEsmShResolver(); const result = rewriteImports(input.source, { diff --git a/apps/e2e/demo-e2e-unix-socket/src/apps/notes/tools/create-note.tool.ts b/apps/e2e/demo-e2e-unix-socket/src/apps/notes/tools/create-note.tool.ts index 3a95e69ca..c0eaa9678 100644 --- a/apps/e2e/demo-e2e-unix-socket/src/apps/notes/tools/create-note.tool.ts +++ b/apps/e2e/demo-e2e-unix-socket/src/apps/notes/tools/create-note.tool.ts @@ -1,6 +1,8 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; -import { randomUUID } from 'crypto'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; +import { randomUUID } from '@frontmcp/utils'; + import { notesStore } from '../data/notes.store'; const inputSchema = { @@ -24,7 +26,7 @@ type CreateNoteOutput = z.infer; inputSchema, outputSchema, }) -export default class CreateNoteTool extends ToolContext { +export default class CreateNoteTool extends ToolContext { async execute(input: CreateNoteInput): Promise { const note = { id: `note-${randomUUID()}`, diff --git a/apps/e2e/demo-e2e-unix-socket/src/apps/notes/tools/get-auth-info.tool.ts b/apps/e2e/demo-e2e-unix-socket/src/apps/notes/tools/get-auth-info.tool.ts index 1b0f21f56..6786a7936 100644 --- a/apps/e2e/demo-e2e-unix-socket/src/apps/notes/tools/get-auth-info.tool.ts +++ b/apps/e2e/demo-e2e-unix-socket/src/apps/notes/tools/get-auth-info.tool.ts @@ -1,6 +1,7 @@ -import { Tool, ToolContext, FRONTMCP_CONTEXT } from '@frontmcp/sdk'; import { z } from 'zod'; +import { FRONTMCP_CONTEXT, Tool, ToolContext } from '@frontmcp/sdk'; + interface DirectAuthInfo { token?: string; user?: { @@ -32,7 +33,7 @@ type GetAuthInfoOutput = z.infer; inputSchema, outputSchema, }) -export default class GetAuthInfoTool extends ToolContext { +export default class GetAuthInfoTool extends ToolContext { async execute(_input: GetAuthInfoInput): Promise { const ctx = this.tryGet(FRONTMCP_CONTEXT); diff --git a/apps/e2e/demo-e2e-unix-socket/src/apps/notes/tools/list-notes.tool.ts b/apps/e2e/demo-e2e-unix-socket/src/apps/notes/tools/list-notes.tool.ts index 225d3fdaa..19ee8668f 100644 --- a/apps/e2e/demo-e2e-unix-socket/src/apps/notes/tools/list-notes.tool.ts +++ b/apps/e2e/demo-e2e-unix-socket/src/apps/notes/tools/list-notes.tool.ts @@ -1,5 +1,7 @@ -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; + +import { Tool, ToolContext } from '@frontmcp/sdk'; + import { notesStore } from '../data/notes.store'; const inputSchema = {}; @@ -25,7 +27,7 @@ type ListNotesOutput = z.infer; inputSchema, outputSchema, }) -export default class ListNotesTool extends ToolContext { +export default class ListNotesTool extends ToolContext { async execute(_input: ListNotesInput): Promise { const notes = notesStore.getAll(); return { diff --git a/docs/docs.json b/docs/docs.json index ca8cdd488..02bd915dc 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -144,6 +144,7 @@ "frontmcp/deployment/frontmcp-config", "frontmcp/deployment/machine-id", "frontmcp/deployment/security-headers", + "frontmcp/deployment/transport-security", "frontmcp/deployment/browser-compatibility", { "group": "Storage", diff --git a/docs/frontmcp/deployment/high-availability.mdx b/docs/frontmcp/deployment/high-availability.mdx index 7f1074542..457db41df 100644 --- a/docs/frontmcp/deployment/high-availability.mdx +++ b/docs/frontmcp/deployment/high-availability.mdx @@ -106,9 +106,20 @@ export default defineConfig({ Set `heartbeatTtlMs` to at least 2x `heartbeatIntervalMs` to avoid false positives from network jitter. +## Orphan Session Scanner + +In addition to on-demand session takeover (when a request arrives for a dead pod's session), FrontMCP runs a periodic **orphan scanner** that proactively detects and claims sessions from dead pods: + +1. Runs every heartbeat interval (default: 10s) +2. Compares all session `nodeId` values against alive heartbeat keys +3. Claims orphaned sessions via the same atomic Lua CAS script +4. Fires a callback for each claimed session (logged at INFO level) + +The scanner starts automatically in distributed mode — no configuration needed. The first scan runs after one full heartbeat interval plus the grace period to allow the cluster to stabilize. + ## Load Balancer Affinity -FrontMCP sets two identifiers on Streamable HTTP responses for load balancer routing: +FrontMCP sets two identifiers on both Streamable HTTP and SSE responses for load balancer routing: - **Cookie**: `__frontmcp_node` --- set during the initialize handshake - **Header**: `X-FrontMCP-Machine-Id` --- set on every response in distributed mode @@ -136,6 +147,34 @@ server { The affinity cookie ensures subsequent requests from the same MCP client hit the same pod. If the pod dies, the load balancer routes to a different pod, which triggers session takeover. +### SSE-Specific Routing + +SSE (Server-Sent Events) requires special attention because the `/sse` endpoint creates a long-lived connection. POST requests to `/message` **must** reach the pod with the active SSE stream. + +FrontMCP handles this in two layers: + +1. **LB Affinity (primary)**: The `__frontmcp_node` cookie is set during SSE initialization, so the load balancer routes subsequent POST requests to the correct pod. +2. **Notification Relay (fallback)**: If a POST arrives at the wrong pod, FrontMCP detects that the session exists on another node and relays the message via Redis Pub/Sub to the owning pod, which delivers it through the active SSE stream. + + + Unlike Streamable HTTP sessions, SSE sessions **cannot be recreated** on a different pod because the SSE response stream is tied to the original HTTP connection. If the owning pod dies, the client must re-establish the SSE connection on a new pod. + + +For NGINX, enable sticky sessions via the affinity cookie and ensure long-lived connections are supported: + +```nginx +server { + location /sse { + proxy_pass http://mcp_backend; + proxy_http_version 1.1; + proxy_set_header Connection ''; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 86400s; + } +} +``` + ## Kubernetes Deploy with 3 replicas and a Redis instance: @@ -216,6 +255,9 @@ redis-cli MONITOR | grep "EVAL" ## Related + + CORS, bind address, DNS rebinding, and host validation + Configure /healthz and /readyz probes @@ -225,7 +267,4 @@ redis-cli MONITOR | grep "EVAL" Standalone, distributed, and serverless modes - - frontmcp.config.ts reference - diff --git a/docs/frontmcp/deployment/transport-security.mdx b/docs/frontmcp/deployment/transport-security.mdx new file mode 100644 index 000000000..63f1f2b6d --- /dev/null +++ b/docs/frontmcp/deployment/transport-security.mdx @@ -0,0 +1,192 @@ +--- +title: Transport Security +description: Configure CORS, bind address, DNS rebinding protection, and host validation for production deployments +--- + +FrontMCP provides transport-level security controls for CORS, network binding, DNS rebinding protection, and host header validation. In development, defaults are permissive for ease of use. In production, FrontMCP logs security warnings and offers a **strict mode** that enables all protections at once. + +## Quick Start + +Enable strict security mode for production: + +```typescript +@FrontMcp({ + http: { + port: 3000, + cors: { + origin: ['https://app.example.com'], + credentials: false, + }, + security: { + strict: true, + dnsRebindingProtection: { + enabled: true, + allowedHosts: ['api.example.com', 'api.example.com:3000'], + allowedOrigins: ['https://app.example.com'], + }, + }, + }, +}) +``` + +## Security Options + +| Field | Type | Default | Description | +| --- | --- | --- | --- | +| `security.strict` | boolean | `false` | Enable all security features at once | +| `security.bindAddress` | `'loopback'` \| `'all'` \| string | `'0.0.0.0'` | Network bind address | +| `security.dnsRebindingProtection.enabled` | boolean | `false` | Validate Host and Origin headers | +| `security.dnsRebindingProtection.allowedHosts` | string[] | --- | Allowed Host header values | +| `security.dnsRebindingProtection.allowedOrigins` | string[] | --- | Allowed Origin header values | + +## CORS Configuration + +By default, FrontMCP uses permissive CORS (`origin: true`) for development convenience. In production, you should restrict origins explicitly: + +```typescript +@FrontMcp({ + http: { + cors: { + origin: ['https://app.example.com', 'https://admin.example.com'], + credentials: false, + maxAge: 600, + }, + }, +}) +``` + +To disable CORS entirely (same-origin only): + +```typescript +@FrontMcp({ + http: { + cors: false, + }, +}) +``` + + + `origin: true` reflects the request origin header, effectively allowing **any** website to make cross-origin requests. This is safe for local development but should never be used in production. + + +## Bind Address + +Controls which network interface the server listens on: + +| Value | Binds To | Use Case | +| --- | --- | --- | +| `'loopback'` | `127.0.0.1` | Local-only access (development, reverse proxy) | +| `'all'` | `0.0.0.0` | All interfaces (distributed pods, direct access) | +| IP string | Specific IP | Custom network binding | + +### Strict Mode Behavior + +When `security.strict: true`: +- **Standalone mode**: binds to `127.0.0.1` (loopback only) +- **Distributed mode**: binds to `0.0.0.0` (pods need external access) + +### Explicit Override + +```typescript +security: { + bindAddress: 'loopback', // Always 127.0.0.1, regardless of deployment mode +} +``` + + + When running behind a reverse proxy (NGINX, Traefik, Envoy), bind to loopback and let the proxy handle external traffic. + + +## DNS Rebinding Protection + +Validates the HTTP `Host` and `Origin` headers against an allowlist. When a request arrives with an unrecognized host, the server responds with `403 Forbidden`. + +```typescript +security: { + dnsRebindingProtection: { + enabled: true, + allowedHosts: ['localhost:3000', 'api.example.com'], + allowedOrigins: ['https://app.example.com'], + }, +} +``` + +- **`allowedHosts`**: Matches the `Host` header exactly (include port if non-standard) +- **`allowedOrigins`**: Matches the `Origin` header exactly (include scheme) +- If `allowedOrigins` is set, requests **without** an `Origin` header are allowed through (non-browser clients) + + + DNS rebinding attacks use a malicious domain that resolves to `127.0.0.1`, tricking a browser into making requests to your local server. Host validation blocks these requests. + + +## Security Audit Warnings + +In production (`NODE_ENV=production`) or distributed mode, FrontMCP logs security warnings at startup: + +``` +[Security] CORS_PERMISSIVE_DEFAULT: CORS is using the permissive default (origin: true). +[Security] BIND_ALL_INTERFACES: Server bound to 0.0.0.0 — accessible from all network interfaces. +[Security] DNS_REBINDING_UNPROTECTED: DNS rebinding protection is disabled. +[Security] STRICT_MODE_HINT: To enable strict security defaults, set security.strict = true. +``` + +These are warnings only — no defaults are changed. Use them to audit your configuration before going to production. + +## Production Checklist + +- [ ] Set explicit `cors.origin` (not `true`) +- [ ] Enable `security.dnsRebindingProtection` with `allowedHosts` +- [ ] Set `security.bindAddress` to `'loopback'` if behind a reverse proxy +- [ ] Configure TLS termination at the reverse proxy layer +- [ ] Set `NODE_ENV=production` for security audit warnings +- [ ] Review startup logs for `[Security]` warnings + +## Example: Full Production Config + +```typescript +@FrontMcp({ + info: { name: 'my-server', version: '1.0.0' }, + apps: [MyApp], + http: { + port: 3000, + cors: { + origin: ['https://app.example.com'], + credentials: false, + maxAge: 600, + }, + security: { + strict: true, + bindAddress: 'loopback', + dnsRebindingProtection: { + enabled: true, + allowedHosts: ['localhost:3000'], + allowedOrigins: ['https://app.example.com'], + }, + }, + }, + redis: { provider: 'redis', host: 'redis', port: 6379 }, + transport: { + protocol: 'modern', + persistence: { + redis: { provider: 'redis', host: 'redis', port: 6379 }, + }, + }, +}) +``` + +## Related + + + + Content Security Policy, HSTS, and X-Frame-Options + + + Distributed sessions, heartbeat, and session takeover + + + Redis connection and session store configuration + + + Build and deploy for production + + diff --git a/libs/sdk/src/channel/reply/channel-reply.tool.ts b/libs/sdk/src/channel/reply/channel-reply.tool.ts index 6792b0d84..6c20c8a3b 100644 --- a/libs/sdk/src/channel/reply/channel-reply.tool.ts +++ b/libs/sdk/src/channel/reply/channel-reply.tool.ts @@ -10,9 +10,10 @@ */ import type { CallToolResult } from '@frontmcp/protocol'; + import { Tool, ToolContext } from '../../common'; -import { channelReplyInputSchema, type ChannelReplyInput } from './reply.types'; import type ChannelRegistry from '../channel.registry'; +import { channelReplyInputSchema, type ChannelReplyInput } from './reply.types'; @Tool({ name: 'channel-reply', @@ -25,7 +26,7 @@ import type ChannelRegistry from '../channel.registry'; openWorldHint: true, }, }) -export class ChannelReplyTool extends ToolContext { +export class ChannelReplyTool extends ToolContext { async execute(input: ChannelReplyInput): Promise { const { channel_name, text, meta } = input; diff --git a/libs/sdk/src/common/entries/scope.entry.ts b/libs/sdk/src/common/entries/scope.entry.ts index 3a25a7a6f..5d1dbe02b 100644 --- a/libs/sdk/src/common/entries/scope.entry.ts +++ b/libs/sdk/src/common/entries/scope.entry.ts @@ -6,6 +6,7 @@ import type AgentRegistry from '../../agent/agent.registry'; import type AppRegistry from '../../app/app.registry'; import type { AuthRegistry } from '../../auth/auth.registry'; import type { ElicitationStore } from '../../elicitation/store/elicitation.store'; +import type { HaManager } from '../../ha'; import type HookRegistry from '../../hooks/hook.registry'; import type { NotificationService } from '../../notification'; import type PromptRegistry from '../../prompt/prompt.registry'; @@ -65,6 +66,8 @@ export abstract class ScopeEntry extends BaseEntry | void; + abstract start(port: number, bindAddress?: string): Promise | void; + abstract start(socketPath: string): Promise | void; + abstract start(portOrSocketPath?: number | string, bindAddress?: string): Promise | void; } diff --git a/libs/sdk/src/common/types/options/http/index.ts b/libs/sdk/src/common/types/options/http/index.ts index b0fcc90e2..10e8de8de 100644 --- a/libs/sdk/src/common/types/options/http/index.ts +++ b/libs/sdk/src/common/types/options/http/index.ts @@ -1,6 +1,6 @@ // common/types/options/http/index.ts // Barrel export for HTTP options -export type { HttpOptionsInterface, CorsOptions } from './interfaces'; +export type { HttpOptionsInterface, CorsOptions, SecurityOptions } from './interfaces'; export { httpOptionsSchema } from './schema'; export type { HttpOptions, HttpOptionsInput } from './schema'; diff --git a/libs/sdk/src/common/types/options/http/interfaces.ts b/libs/sdk/src/common/types/options/http/interfaces.ts index ce2a5329f..5da07266c 100644 --- a/libs/sdk/src/common/types/options/http/interfaces.ts +++ b/libs/sdk/src/common/types/options/http/interfaces.ts @@ -1,7 +1,7 @@ // common/types/options/http/interfaces.ts // Explicit TypeScript interfaces for HTTP configuration -import { FrontMcpServer } from '../../../interfaces'; +import { type FrontMcpServer } from '../../../interfaces'; /** * Framework-agnostic CORS configuration options. @@ -70,4 +70,48 @@ export interface HttpOptionsInterface { * - `CorsOptions`: custom CORS configuration */ cors?: CorsOptions | false; + + /** + * Security configuration for transport hardening. + * These options are opt-in — defaults remain backwards-compatible. + * Set `strict: true` to enable all security features at once. + */ + security?: SecurityOptions; +} + +/** + * Security options for transport hardening. + */ +export interface SecurityOptions { + /** + * Enable strict security defaults. + * When true: loopback binding (standalone), restrictive CORS, DNS rebinding protection. + * @default false + */ + strict?: boolean; + + /** + * Network bind address override. + * - `'loopback'`: bind to 127.0.0.1 (local access only) + * - `'all'`: bind to 0.0.0.0 (all interfaces) + * - string: specific IP address + * + * Default (no strict): '0.0.0.0' (backwards compatible) + * Default (strict, standalone): '127.0.0.1' + * Default (strict, distributed): '0.0.0.0' + */ + bindAddress?: 'loopback' | 'all' | string; + + /** + * DNS rebinding protection configuration. + * When enabled, validates Host and Origin headers on incoming requests. + */ + dnsRebindingProtection?: { + /** Enable host/origin header validation. @default false */ + enabled?: boolean; + /** Allowed Host header values (e.g., ['localhost:3001', 'api.example.com']) */ + allowedHosts?: string[]; + /** Allowed Origin header values (e.g., ['https://app.example.com']) */ + allowedOrigins?: string[]; + }; } diff --git a/libs/sdk/src/common/types/options/http/schema.ts b/libs/sdk/src/common/types/options/http/schema.ts index face25975..673ec4f6b 100644 --- a/libs/sdk/src/common/types/options/http/schema.ts +++ b/libs/sdk/src/common/types/options/http/schema.ts @@ -2,6 +2,7 @@ // Zod schema for HTTP configuration import { z } from 'zod'; + import type { RawZodShape } from '../../common.types'; import type { CorsOptions, HttpOptionsInterface } from './interfaces'; @@ -49,6 +50,23 @@ export const httpOptionsSchema = z.object({ * - CorsOptions object: custom CORS config */ cors: z.union([z.literal(false), corsOptionsSchema]).optional(), + /** + * Security configuration for transport hardening. + * Opt-in — defaults remain backwards-compatible. + */ + security: z + .object({ + strict: z.boolean().optional(), + bindAddress: z.union([z.literal('loopback'), z.literal('all'), z.string()]).optional(), + dnsRebindingProtection: z + .object({ + enabled: z.boolean().optional(), + allowedHosts: z.array(z.string()).optional(), + allowedOrigins: z.array(z.string()).optional(), + }) + .optional(), + }) + .optional(), } satisfies RawZodShape); /** diff --git a/libs/sdk/src/direct/__tests__/create-e2e.spec.ts b/libs/sdk/src/direct/__tests__/create-e2e.spec.ts index 3ba7d1779..d6cc2123f 100644 --- a/libs/sdk/src/direct/__tests__/create-e2e.spec.ts +++ b/libs/sdk/src/direct/__tests__/create-e2e.spec.ts @@ -21,7 +21,7 @@ import type { DirectMcpServer } from '../direct.types'; const echoInput = { message: z.string() }; @Tool({ name: 'echo', description: 'Echoes the message', inputSchema: echoInput }) -class EchoTool extends ToolContext { +class EchoTool extends ToolContext { async execute(input: z.infer>): Promise { return { content: [{ type: 'text', text: `Echo: ${input.message}` }] }; } diff --git a/libs/sdk/src/elicitation/README.md b/libs/sdk/src/elicitation/README.md index f1908a660..f01a6b6c3 100644 --- a/libs/sdk/src/elicitation/README.md +++ b/libs/sdk/src/elicitation/README.md @@ -16,9 +16,10 @@ Elicitation allows tools and agents to pause execution and request structured in ### Basic Usage in Tools ```typescript -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + const inputSchema = { action: z.string(), }; @@ -28,7 +29,7 @@ const inputSchema = { description: 'Delete a file with user confirmation', inputSchema, }) -class DeleteFileTool extends ToolContext { +class DeleteFileTool extends ToolContext { async execute(input: { action: string }) { // Request user confirmation before deleting const result = await this.elicit( @@ -173,7 +174,7 @@ You can chain multiple elicitations for complex workflows: ```typescript @Tool({ name: 'multi-step-wizard', inputSchema, description: '...' }) -class MultiStepWizardTool extends ToolContext { +class MultiStepWizardTool extends ToolContext { async execute(input: { topic: string }) { // Step 1: Get basic info const step1 = await this.elicit('Step 1: Enter your name', z.object({ name: z.string() })); @@ -310,8 +311,12 @@ const result = await this.elicit(message, schema, { ttl: 60000 }); // 1 minute DirectClient supports elicitation handling for programmatic MCP access: ```typescript -import { connect } from '@frontmcp/sdk/direct'; -import type { ElicitationHandler, ElicitationRequest, ElicitationResponse } from '@frontmcp/sdk/direct'; +import { + connect, + type ElicitationHandler, + type ElicitationRequest, + type ElicitationResponse, +} from '@frontmcp/sdk/direct'; const client = await connect(scope); diff --git a/libs/sdk/src/elicitation/send-elicitation-result.tool.ts b/libs/sdk/src/elicitation/send-elicitation-result.tool.ts index e09453565..1504fd02b 100644 --- a/libs/sdk/src/elicitation/send-elicitation-result.tool.ts +++ b/libs/sdk/src/elicitation/send-elicitation-result.tool.ts @@ -13,7 +13,9 @@ */ import { z } from 'zod'; + import type { CallToolResult } from '@frontmcp/protocol'; + import { Tool, ToolContext } from '../common'; import type { ElicitResult, ElicitStatus } from './elicitation.types'; @@ -46,7 +48,7 @@ type SendElicitationResultInput = z.infer>; // Hidden by default, only shown to clients that don't support elicitation hideFromDiscovery: true, }) -export class SendElicitationResultTool extends ToolContext { +export class SendElicitationResultTool extends ToolContext { async execute(input: SendElicitationResultInput): Promise { const { elicitId, action, content } = input; diff --git a/libs/sdk/src/ha/__tests__/ha-manager.spec.ts b/libs/sdk/src/ha/__tests__/ha-manager.spec.ts index 112b5d140..94843c0a8 100644 --- a/libs/sdk/src/ha/__tests__/ha-manager.spec.ts +++ b/libs/sdk/src/ha/__tests__/ha-manager.spec.ts @@ -127,4 +127,129 @@ describe('HaManager', () => { await manager.stop(); expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Services stopped')); }); + + describe('orphan scanner', () => { + it('should start the scanner without error', async () => { + const redis = createMockRedis(); + const logger = { info: jest.fn(), warn: jest.fn(), debug: jest.fn() }; + const manager = HaManager.create({ redis: redis as never, nodeId: 'pod-a', logger }); + await manager.start(); + + const onOrphan = jest.fn(); + manager.startOrphanScanner({ + sessionKeyPrefix: 'mcp:transport:', + onOrphan, + }); + + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Orphan scanner started')); + + await manager.stop(); + }); + + it('should not start the scanner twice', async () => { + const redis = createMockRedis(); + const logger = { info: jest.fn(), warn: jest.fn(), debug: jest.fn() }; + const manager = HaManager.create({ redis: redis as never, nodeId: 'pod-a', logger }); + await manager.start(); + + const onOrphan = jest.fn(); + manager.startOrphanScanner({ sessionKeyPrefix: 'mcp:transport:', onOrphan }); + manager.startOrphanScanner({ sessionKeyPrefix: 'mcp:transport:', onOrphan }); + + // Should only log once + const scannerLogs = (logger.info as jest.Mock).mock.calls.filter( + (c: string[]) => typeof c[0] === 'string' && c[0].includes('Orphan scanner started'), + ); + expect(scannerLogs).toHaveLength(1); + + await manager.stop(); + }); + + it('should stop scanner when manager stops', async () => { + const redis = createMockRedis(); + const manager = HaManager.create({ redis: redis as never, nodeId: 'pod-a' }); + await manager.start(); + + manager.startOrphanScanner({ + sessionKeyPrefix: 'mcp:transport:', + onOrphan: jest.fn(), + }); + + await manager.stop(); + expect(manager.isStarted()).toBe(false); + }); + + it('should claim orphaned sessions via atomic takeover', async () => { + const redis = createMockRedis(); + const logger = { info: jest.fn(), warn: jest.fn(), debug: jest.fn() }; + const manager = HaManager.create({ + redis: redis as never, + nodeId: 'pod-b', + logger, + config: { + heartbeatIntervalMs: 50, + heartbeatTtlMs: 200, + takeoverGracePeriodMs: 0, + }, + }); + await manager.start(); + + // Simulate an orphaned session owned by dead pod-a + const sessionData = JSON.stringify({ + session: { id: 'sess-1', nodeId: 'pod-a', protocol: 'streamable-http' }, + authorizationId: 'hash123', + createdAt: Date.now(), + lastAccessedAt: Date.now(), + }); + redis.store.set('mcp:transport:sess-1', { value: sessionData, expiresAt: Date.now() + 60_000 }); + + // Mock eval to simulate successful CAS takeover + redis.eval.mockResolvedValueOnce(1); + + const onOrphan = jest.fn(); + manager.startOrphanScanner({ + sessionKeyPrefix: 'mcp:transport:', + onOrphan, + }); + + // Wait for initial delay (intervalMs + gracePeriodMs) + scan to run + await new Promise((r) => setTimeout(r, 200)); + + expect(onOrphan).toHaveBeenCalledWith('sess-1', 'pod-a'); + + await manager.stop(); + }); + + it('should not claim sessions owned by alive nodes', async () => { + const redis = createMockRedis(); + const manager = HaManager.create({ + redis: redis as never, + nodeId: 'pod-b', + config: { + heartbeatIntervalMs: 50, + heartbeatTtlMs: 200, + takeoverGracePeriodMs: 0, + }, + }); + await manager.start(); + + // Simulate a session owned by pod-b (ourselves — alive) + const sessionData = JSON.stringify({ + session: { id: 'sess-2', nodeId: 'pod-b', protocol: 'streamable-http' }, + }); + redis.store.set('mcp:transport:sess-2', { value: sessionData, expiresAt: Date.now() + 60_000 }); + + const onOrphan = jest.fn(); + manager.startOrphanScanner({ + sessionKeyPrefix: 'mcp:transport:', + onOrphan, + }); + + await new Promise((r) => setTimeout(r, 200)); + + expect(onOrphan).not.toHaveBeenCalled(); + + await manager.stop(); + }); + }); }); diff --git a/libs/sdk/src/ha/ha-manager.ts b/libs/sdk/src/ha/ha-manager.ts index 9301b750a..e2d402c1a 100644 --- a/libs/sdk/src/ha/ha-manager.ts +++ b/libs/sdk/src/ha/ha-manager.ts @@ -1,7 +1,8 @@ /** * HA Manager — Lifecycle Coordinator * - * Orchestrates heartbeat, session takeover, and notification relay. + * Orchestrates heartbeat, session takeover, notification relay, + * and orphan session scanning. * Created by Scope when deployment mode is 'distributed'. */ @@ -11,6 +12,28 @@ import { HeartbeatService, type HeartbeatRedisClient } from './heartbeat.service import { NotificationRelay, type RelayHandler, type RelayRedisClient } from './notification-relay'; import { attemptSessionTakeover, type TakeoverRedisClient } from './session-takeover'; +/** + * Callback invoked when an orphaned session is successfully claimed. + */ +export type OrphanHandler = (sessionId: string, previousNodeId: string) => void | Promise; + +/** + * Options for the orphan session scanner. + */ +export interface OrphanScannerOptions { + /** Redis key prefix for session keys (e.g., 'mcp:transport:'). */ + sessionKeyPrefix: string; + /** Callback when a session is successfully claimed. */ + onOrphan: OrphanHandler; + /** + * Protocols that can be recreated after takeover. + * Sessions with protocols NOT in this list are skipped (e.g., SSE sessions + * cannot be recreated because the SSE stream is tied to the original connection). + * @default ['streamable-http'] + */ + recreatableProtocols?: string[]; +} + /** * Options for creating an HaManager. */ @@ -35,6 +58,11 @@ export class HaManager { private readonly config: HaConfig; private readonly logger: HaManagerOptions['logger']; private started = false; + private scannerBootstrapTimer: ReturnType | undefined; + private scannerTimer: ReturnType | undefined; + private scannerOptions: OrphanScannerOptions | undefined; + private scanning = false; + private scannerStarted = false; private constructor( private readonly redis: HeartbeatRedisClient & TakeoverRedisClient, @@ -78,11 +106,50 @@ export class HaManager { } } + /** + * Start the orphan session scanner. + * + * Runs periodically (on heartbeat interval) to detect sessions owned by + * dead nodes and claim them via atomic Lua CAS. The `onOrphan` callback + * is fired for each successfully claimed session. + * + * @param options - Scanner configuration + */ + startOrphanScanner(options: OrphanScannerOptions): void { + if (this.scannerStarted) return; + this.scannerStarted = true; + this.scannerOptions = options; + + // Run first scan after one full heartbeat interval + grace period + // (allow heartbeats to stabilize before scanning) + const delay = this.config.heartbeatIntervalMs + this.config.takeoverGracePeriodMs; + + this.scannerBootstrapTimer = setTimeout(() => { + if (!this.started || !this.scannerStarted) return; + void this.runOrphanScan(); + this.scannerTimer = setInterval(() => void this.runOrphanScan(), this.config.heartbeatIntervalMs); + this.scannerTimer.unref?.(); + }, delay); + this.scannerBootstrapTimer.unref?.(); + + this.logger?.info(`[HA] Orphan scanner started (interval: ${this.config.heartbeatIntervalMs}ms)`); + } + /** Stop all HA services. */ async stop(): Promise { if (!this.started) return; this.started = false; + if (this.scannerBootstrapTimer) { + clearTimeout(this.scannerBootstrapTimer); + this.scannerBootstrapTimer = undefined; + } + if (this.scannerTimer) { + clearInterval(this.scannerTimer); + this.scannerTimer = undefined; + } + this.scannerStarted = false; + await this.heartbeat.stop(); if (this.relay) { await this.relay.unsubscribe(); @@ -145,4 +212,79 @@ export class HaManager { getNodeId(): string { return this.nodeId; } + + /** + * Run a single orphan scan cycle. + * + * 1. Get alive nodes from heartbeat keys + * 2. Scan session keys matching the configured prefix + * 3. Parse each session's nodeId + * 4. If nodeId is not in the alive set, attempt takeover + * 5. Fire onOrphan callback for each claimed session + */ + private async runOrphanScan(): Promise { + if (!this.scannerOptions || this.scanning) return; + this.scanning = true; + + try { + const aliveNodes = new Set(await this.heartbeat.getAliveNodes()); + + // Skip scan if we can't determine alive nodes (Redis issue) + if (aliveNodes.size === 0) { + this.logger?.debug('[HA] Orphan scan skipped: no alive nodes detected (Redis may be unavailable)'); + return; + } + + const { sessionKeyPrefix, onOrphan, recreatableProtocols } = this.scannerOptions; + const allowedProtocols = new Set(recreatableProtocols ?? ['streamable-http']); + const sessionKeys = await this.redis.keys(`${sessionKeyPrefix}*`); + + let claimed = 0; + let scanned = 0; + + for (const key of sessionKeys) { + scanned++; + try { + const raw = await this.redis.get(key); + if (!raw) continue; + + const data = JSON.parse(raw); + const sessionNodeId = data?.session?.nodeId ?? data?.nodeId; + if (!sessionNodeId) continue; + + // Skip sessions with non-recreatable protocols (e.g., SSE) + const protocol = data?.session?.protocol; + if (protocol && !allowedProtocols.has(protocol)) continue; + + // Skip sessions owned by alive nodes (including ourselves) + if (aliveNodes.has(sessionNodeId)) continue; + + // Dead node — attempt takeover + const result = await attemptSessionTakeover(this.redis, key, sessionNodeId, this.nodeId); + if (result.claimed) { + claimed++; + const sessionId = key.slice(sessionKeyPrefix.length); + this.logger?.info(`[HA] Orphan scan: claimed session ${sessionId.slice(0, 20)} from ${sessionNodeId}`); + + // Fire callback (best-effort) + try { + await Promise.resolve(onOrphan(sessionId, sessionNodeId)); + } catch (err) { + this.logger?.warn(`[HA] Orphan handler error for ${sessionId.slice(0, 20)}: ${err}`); + } + } + } catch { + // Skip individual session errors — continue scanning + } + } + + if (scanned > 0) { + this.logger?.debug(`[HA] Orphan scan complete: scanned=${scanned}, claimed=${claimed}`); + } + } catch (err) { + this.logger?.warn(`[HA] Orphan scan failed: ${err}`); + } finally { + this.scanning = false; + } + } } diff --git a/libs/sdk/src/plugin/__tests__/plugin-skills.spec.ts b/libs/sdk/src/plugin/__tests__/plugin-skills.spec.ts index cba56576c..650879c07 100644 --- a/libs/sdk/src/plugin/__tests__/plugin-skills.spec.ts +++ b/libs/sdk/src/plugin/__tests__/plugin-skills.spec.ts @@ -6,12 +6,14 @@ */ import 'reflect-metadata'; -import PluginRegistry from '../plugin.registry'; -import SkillRegistry from '../../skill/skill.registry'; -import { Plugin, Skill, SkillContext, SkillContent, Tool, ToolContext } from '../../common'; + +import { z } from 'zod'; + import { createProviderRegistryWithScope } from '../../__test-utils__/fixtures/scope.fixtures'; +import { Plugin, Skill, SkillContext, Tool, ToolContext, type SkillContent } from '../../common'; import { skill } from '../../common/decorators/skill.decorator'; -import { z } from 'zod'; +import type SkillRegistry from '../../skill/skill.registry'; +import PluginRegistry from '../plugin.registry'; // Mock SkillContext for class-based skills class MockSkillContext extends SkillContext { @@ -429,7 +431,7 @@ describe('Plugin Skills', () => { description: 'A tool from plugin', inputSchema, }) - class PluginTool extends ToolContext { + class PluginTool extends ToolContext { async execute() { return { success: true }; } diff --git a/libs/sdk/src/scope/scope.instance.ts b/libs/sdk/src/scope/scope.instance.ts index 032a0ed8c..fa1c31c20 100644 --- a/libs/sdk/src/scope/scope.instance.ts +++ b/libs/sdk/src/scope/scope.instance.ts @@ -65,6 +65,7 @@ import { ToolInstance } from '../tool/tool.instance'; import ToolRegistry from '../tool/tool.registry'; import { normalizeTool } from '../tool/tool.utils'; import { hasUIConfig, StaticWidgetResourceTemplate, ToolUIRegistry } from '../tool/ui'; +import { RedisTransportBus } from '../transport/bus'; import { createEventStore } from '../transport/event-stores'; import { TransportService } from '../transport/transport.registry'; import type WorkflowRegistry from '../workflow/workflow.registry'; @@ -89,7 +90,7 @@ export class Scope extends ScopeEntry { transportService: TransportService; // TODO: migrate transport service to transport.registry notificationService: NotificationService; - haManager?: HaManager; + declare haManager?: HaManager; private toolUIRegistry: ToolUIRegistry; readonly entryPath: string; readonly routeBase: string; @@ -179,12 +180,11 @@ export class Scope extends ScopeEntry { this.scopeApps = new AppRegistry(this.scopeProviders, this.metadata.apps, scopeRef); this.logger.info(`Initializing ${this.metadata.apps.length} app(s)...`); - // TransportService is synchronous — no await needed - const transportConfig = this.metadata.transport; - this.transportService = new TransportService(this, transportConfig?.persistence); - // HA Manager (conditional — only in distributed deployment mode) - if (getRuntimeContext().deployment === 'distributed' && this.metadata.redis && !this.cliMode) { + // Must be created before TransportService so the bus can be passed to it. + const transportConfig = this.metadata.transport; + const isDistributed = getRuntimeContext().deployment === 'distributed'; + if (isDistributed && this.metadata.redis && !this.cliMode) { try { const haRedis = this.metadata.redis; this.haManager = HaManager.create({ @@ -199,6 +199,35 @@ export class Scope extends ScopeEntry { } } + // Transport bus (distributed mode only — requires HA Manager + Redis) + let transportBus: InstanceType | undefined; + if (this.haManager && isDistributed && this.metadata.redis) { + transportBus = new RedisTransportBus(this.metadata.redis as never, getMachineId(), { + logger: this.logger, + }); + this.logger.info('[HA] Transport bus created for distributed session routing'); + } + + // TransportService — pass the bus for distributed mode + this.transportService = new TransportService(this, transportConfig?.persistence, transportBus); + + // Orphan session scanner (distributed mode only — scans for dead-pod sessions) + if (this.haManager && isDistributed) { + const persistCfg = transportConfig?.persistence; + const resolvedPrefix = + persistCfg && typeof persistCfg === 'object' && persistCfg.redis?.keyPrefix + ? persistCfg.redis.keyPrefix + : 'mcp:transport:'; + this.haManager.startOrphanScanner({ + sessionKeyPrefix: resolvedPrefix, + onOrphan: (sessionId, previousNodeId) => { + this.logger.info( + `[HA] Orphaned session ${sessionId.slice(0, 20)} from ${previousNodeId} — available for recreation`, + ); + }, + }); + } + // Serverless SSE warning if (getRuntimeContext().deployment === 'serverless') { const protocolCfg = transportConfig?.protocol; @@ -212,12 +241,18 @@ export class Scope extends ScopeEntry { } // EventStore (conditional, skipped in CLI mode) - const eventStoreConfig = transportConfig?.eventStore; - if (eventStoreConfig?.enabled && !this.cliMode) { - const { eventStore } = createEventStore(eventStoreConfig, this.logger); + // In distributed mode, auto-enable Redis event store for cross-pod SSE resumability + const effectiveEventStoreConfig = + transportConfig?.eventStore ?? + (isDistributed && this.metadata.redis && !this.cliMode + ? { enabled: true, provider: 'redis' as const, redis: this.metadata.redis } + : undefined); + if (effectiveEventStoreConfig?.enabled && !this.cliMode) { + const { eventStore } = createEventStore(effectiveEventStoreConfig, this.logger); this._eventStore = eventStore; this.logger.info('EventStore initialized for SSE resumability', { - provider: eventStoreConfig.provider ?? 'memory', + provider: effectiveEventStoreConfig.provider ?? 'memory', + autoEnabled: !transportConfig?.eventStore && isDistributed, }); } diff --git a/libs/sdk/src/server/__tests__/server.instance.spec.ts b/libs/sdk/src/server/__tests__/server.instance.spec.ts index bda55da9c..cddfea3fe 100644 --- a/libs/sdk/src/server/__tests__/server.instance.spec.ts +++ b/libs/sdk/src/server/__tests__/server.instance.spec.ts @@ -1,7 +1,7 @@ // server/__tests__/server.instance.spec.ts +import { type FrontMcpServer } from '../../common'; import { FrontMcpServerInstance } from '../server.instance'; -import { FrontMcpServer } from '../../common'; // Capture constructor args passed to ExpressHostAdapter let capturedAdapterArgs: unknown[] = []; @@ -41,11 +41,11 @@ describe('FrontMcpServerInstance', () => { }); }); - it('should pass no options when cors is false', () => { + it('should pass empty options when cors is false', () => { new FrontMcpServerInstance({ port: 3001, entryPath: '', cors: false }); expect(capturedAdapterArgs).toHaveLength(1); - expect(capturedAdapterArgs[0]).toBeUndefined(); + expect(capturedAdapterArgs[0]).toEqual({}); }); it('should pass custom cors config through to adapter', () => { diff --git a/libs/sdk/src/server/adapters/base.host.adapter.ts b/libs/sdk/src/server/adapters/base.host.adapter.ts index 33f82897b..8412a76ec 100644 --- a/libs/sdk/src/server/adapters/base.host.adapter.ts +++ b/libs/sdk/src/server/adapters/base.host.adapter.ts @@ -16,6 +16,9 @@ export abstract class HostServerAdapter extends FrontMcpServer { * Start the server on the specified port or Unix socket path. * When a string is provided, the server listens on a Unix socket. * When a number is provided, the server listens on a TCP port. + * + * @param portOrSocketPath - Port number or Unix socket path + * @param bindAddress - Optional bind address (e.g., '127.0.0.1', '0.0.0.0') */ - abstract override start(portOrSocketPath: number | string): Promise | void; + abstract override start(portOrSocketPath: number | string, bindAddress?: string): Promise | void; } diff --git a/libs/sdk/src/server/adapters/express.host.adapter.ts b/libs/sdk/src/server/adapters/express.host.adapter.ts index 7c9da332a..5a44c9d8b 100644 --- a/libs/sdk/src/server/adapters/express.host.adapter.ts +++ b/libs/sdk/src/server/adapters/express.host.adapter.ts @@ -1,11 +1,22 @@ // server/adapters/express.host.adapter.ts import * as http from 'node:http'; -import express from 'express'; + import cors from 'cors'; -import { HostServerAdapter } from './base.host.adapter'; -import { CorsOptions, HttpMethod, ServerRequest, ServerRequestHandler, ServerResponse } from '../../common'; +import express from 'express'; + import { fileExists, unlink } from '@frontmcp/utils'; +import { + type CorsOptions, + type HttpMethod, + type ServerRequest, + type ServerRequestHandler, + type ServerResponse, +} from '../../common'; +import type { SecurityOptions } from '../../common/types/options/http/interfaces'; +import { createHostValidationMiddleware } from '../middleware/host-validation.middleware'; +import { HostServerAdapter } from './base.host.adapter'; + /** * Options for ExpressHostAdapter. */ @@ -16,6 +27,12 @@ export interface ExpressHostAdapterOptions { * Note: FrontMcpServerInstance provides a permissive default when `cors` is omitted. */ cors?: CorsOptions; + + /** + * Security options for transport hardening. + * Includes bind address and DNS rebinding protection. + */ + security?: SecurityOptions; } export class ExpressHostAdapter extends HostServerAdapter { @@ -43,6 +60,30 @@ export class ExpressHostAdapter extends HostServerAdapter { ); } + // Host validation middleware (DNS rebinding protection) + const securityOpts = options?.security; + if (securityOpts?.dnsRebindingProtection?.enabled || securityOpts?.strict) { + const allowedHosts = securityOpts.dnsRebindingProtection?.allowedHosts; + const allowedOrigins = securityOpts.dnsRebindingProtection?.allowedOrigins; + + // In strict mode without explicit allowedHosts, derive from localhost + const effectiveHosts = allowedHosts ?? (securityOpts.strict ? ['localhost', '127.0.0.1'] : undefined); + + if (!effectiveHosts?.length && !allowedOrigins?.length) { + throw new Error( + 'security.dnsRebindingProtection is enabled but no allowedHosts or allowedOrigins are configured. ' + + 'Provide at least one allowedHosts entry or disable dnsRebindingProtection.', + ); + } + + const hostValidation = createHostValidationMiddleware({ + enabled: true, + allowedHosts: effectiveHosts, + allowedOrigins, + }); + this.app.use(hostValidation as express.RequestHandler); + } + // When creating the HTTP(S) server that hosts /mcp: this.app.use((req, res, next) => { // Only set CORS-specific headers when CORS is enabled @@ -92,7 +133,7 @@ export class ExpressHostAdapter extends HostServerAdapter { return this.app; } - async start(portOrSocketPath: number | string) { + async start(portOrSocketPath: number | string, bindAddress?: string) { this.prepare(); const server = http.createServer(this.app); server.requestTimeout = 0; @@ -118,10 +159,11 @@ export class ExpressHostAdapter extends HostServerAdapter { }); }); } else { + const host = bindAddress ?? '0.0.0.0'; await new Promise((resolve, reject) => { server.on('error', reject); - server.listen(portOrSocketPath, () => { - console.log(`MCP HTTP (Express) on ${portOrSocketPath}`); + server.listen(portOrSocketPath, host, () => { + console.log(`MCP HTTP (Express) on ${host}:${portOrSocketPath}`); resolve(); }); }); diff --git a/libs/sdk/src/server/middleware/__tests__/host-validation.middleware.spec.ts b/libs/sdk/src/server/middleware/__tests__/host-validation.middleware.spec.ts new file mode 100644 index 000000000..aca8d1b53 --- /dev/null +++ b/libs/sdk/src/server/middleware/__tests__/host-validation.middleware.spec.ts @@ -0,0 +1,115 @@ +import type { ServerRequest, ServerResponse } from '../../../common'; +import { createHostValidationMiddleware } from '../host-validation.middleware'; + +function createMockReq(headers: Record = {}): ServerRequest { + return { headers } as unknown as ServerRequest; +} + +function createMockRes(): ServerResponse & { statusCode?: number; body?: unknown } { + const res = { + statusCode: undefined as number | undefined, + body: undefined as unknown, + status(code: number) { + res.statusCode = code; + return res; + }, + json(data: unknown) { + res.body = data; + return res; + }, + }; + return res as unknown as ServerResponse & { statusCode?: number; body?: unknown }; +} + +describe('createHostValidationMiddleware()', () => { + it('returns no-op middleware when not enabled', () => { + const middleware = createHostValidationMiddleware({ enabled: false }); + const next = jest.fn(); + middleware(createMockReq(), createMockRes() as ServerResponse, next); + expect(next).toHaveBeenCalled(); + }); + + describe('when enabled with allowedHosts', () => { + const middleware = createHostValidationMiddleware({ + enabled: true, + allowedHosts: ['localhost:3001', 'api.example.com'], + }); + + it('passes valid host', () => { + const next = jest.fn(); + middleware(createMockReq({ host: 'localhost:3001' }), createMockRes() as ServerResponse, next); + expect(next).toHaveBeenCalled(); + }); + + it('rejects invalid host with 403', () => { + const next = jest.fn(); + const res = createMockRes(); + middleware(createMockReq({ host: 'evil.com' }), res as ServerResponse, next); + expect(next).not.toHaveBeenCalled(); + expect(res.statusCode).toBe(403); + expect(res.body).toEqual(expect.objectContaining({ error: 'Forbidden' })); + }); + + it('rejects missing host with 403', () => { + const next = jest.fn(); + const res = createMockRes(); + middleware(createMockReq({}), res as ServerResponse, next); + expect(next).not.toHaveBeenCalled(); + expect(res.statusCode).toBe(403); + }); + }); + + describe('when enabled with allowedOrigins', () => { + const middleware = createHostValidationMiddleware({ + enabled: true, + allowedOrigins: ['https://app.example.com'], + }); + + it('passes when no Origin header present', () => { + const next = jest.fn(); + middleware(createMockReq({}), createMockRes() as ServerResponse, next); + expect(next).toHaveBeenCalled(); + }); + + it('passes valid Origin', () => { + const next = jest.fn(); + middleware(createMockReq({ origin: 'https://app.example.com' }), createMockRes() as ServerResponse, next); + expect(next).toHaveBeenCalled(); + }); + + it('rejects invalid Origin with 403', () => { + const next = jest.fn(); + const res = createMockRes(); + middleware(createMockReq({ origin: 'https://evil.com' }), res as ServerResponse, next); + expect(next).not.toHaveBeenCalled(); + expect(res.statusCode).toBe(403); + expect(res.body).toEqual(expect.objectContaining({ message: 'Invalid Origin header' })); + }); + }); + + describe('when enabled with both allowedHosts and allowedOrigins', () => { + const middleware = createHostValidationMiddleware({ + enabled: true, + allowedHosts: ['localhost:3001'], + allowedOrigins: ['https://app.example.com'], + }); + + it('passes when both are valid', () => { + const next = jest.fn(); + middleware( + createMockReq({ host: 'localhost:3001', origin: 'https://app.example.com' }), + createMockRes() as ServerResponse, + next, + ); + expect(next).toHaveBeenCalled(); + }); + + it('rejects when host is invalid (even if origin is valid)', () => { + const next = jest.fn(); + const res = createMockRes(); + middleware(createMockReq({ host: 'evil.com', origin: 'https://app.example.com' }), res as ServerResponse, next); + expect(next).not.toHaveBeenCalled(); + expect(res.statusCode).toBe(403); + }); + }); +}); diff --git a/libs/sdk/src/server/middleware/host-validation.middleware.ts b/libs/sdk/src/server/middleware/host-validation.middleware.ts new file mode 100644 index 000000000..b03d77763 --- /dev/null +++ b/libs/sdk/src/server/middleware/host-validation.middleware.ts @@ -0,0 +1,64 @@ +/** + * Host Header Validation Middleware + * + * Validates the HTTP Host header against a whitelist of allowed values + * to protect against DNS rebinding attacks. Only active when explicitly + * enabled via security.dnsRebindingProtection.enabled = true. + */ + +import type { ServerRequest, ServerResponse } from '../../common'; + +/** + * Configuration for host validation middleware. + */ +export interface HostValidationOptions { + /** Whether host validation is enabled. @default false */ + enabled: boolean; + /** Allowed Host header values (e.g., ['localhost:3001', 'api.example.com']) */ + allowedHosts?: string[]; + /** Allowed Origin header values (e.g., ['https://app.example.com']) */ + allowedOrigins?: string[]; +} + +/** + * Create middleware that validates Host and Origin headers. + * Returns a no-op middleware when not enabled. + */ +export function createHostValidationMiddleware( + options: HostValidationOptions, +): (req: ServerRequest, res: ServerResponse, next: () => void) => void { + if (!options.enabled) { + return (_req, _res, next) => next(); + } + + const allowedHostsSet = options.allowedHosts ? new Set(options.allowedHosts) : undefined; + const allowedOriginsSet = options.allowedOrigins ? new Set(options.allowedOrigins) : undefined; + + return (req, res, next) => { + // Validate Host header + if (allowedHostsSet) { + const host = req.headers?.['host']; + if (!host || !allowedHostsSet.has(host as string)) { + res.status(403).json({ + error: 'Forbidden', + message: 'Invalid Host header', + }); + return; + } + } + + // Validate Origin header (only if present and allowedOrigins configured) + if (allowedOriginsSet) { + const origin = req.headers?.['origin'] as string | undefined; + if (origin && !allowedOriginsSet.has(origin)) { + res.status(403).json({ + error: 'Forbidden', + message: 'Invalid Origin header', + }); + return; + } + } + + next(); + }; +} diff --git a/libs/sdk/src/server/security/__tests__/security-audit.spec.ts b/libs/sdk/src/server/security/__tests__/security-audit.spec.ts new file mode 100644 index 000000000..29acfb81e --- /dev/null +++ b/libs/sdk/src/server/security/__tests__/security-audit.spec.ts @@ -0,0 +1,188 @@ +import { + auditSecurityDefaults, + logSecurityFindings, + resolveBindAddress, + type SecurityAuditConfig, + type SecurityFinding, +} from '../security-audit'; + +describe('auditSecurityDefaults()', () => { + it('returns no findings in development mode (non-distributed)', () => { + const findings = auditSecurityDefaults({}, false); + expect(findings).toEqual([]); + }); + + it('returns findings in production mode', () => { + const findings = auditSecurityDefaults({}, true); + expect(findings.length).toBeGreaterThan(0); + }); + + it('returns findings in distributed mode even if not production', () => { + const findings = auditSecurityDefaults({ deploymentMode: 'distributed' }, false); + expect(findings.length).toBeGreaterThan(0); + }); + + describe('CORS audit', () => { + it('warns when CORS is using permissive default (undefined)', () => { + const findings = auditSecurityDefaults({ cors: undefined }, true); + const corsFinding = findings.find((f) => f.code === 'CORS_PERMISSIVE_DEFAULT'); + expect(corsFinding).toBeDefined(); + expect(corsFinding!.level).toBe('warn'); + }); + + it('warns when origin is explicitly true', () => { + const findings = auditSecurityDefaults({ cors: { origin: true } }, true); + const corsFinding = findings.find((f) => f.code === 'CORS_ORIGIN_TRUE'); + expect(corsFinding).toBeDefined(); + expect(corsFinding!.level).toBe('warn'); + }); + + it('info when CORS is disabled', () => { + const findings = auditSecurityDefaults({ cors: false }, true); + const corsFinding = findings.find((f) => f.code === 'CORS_DISABLED'); + expect(corsFinding).toBeDefined(); + expect(corsFinding!.level).toBe('info'); + }); + + it('info when CORS is explicitly configured', () => { + const findings = auditSecurityDefaults({ cors: { origin: 'https://example.com' } }, true); + const corsFinding = findings.find((f) => f.code === 'CORS_CONFIGURED'); + expect(corsFinding).toBeDefined(); + expect(corsFinding!.level).toBe('info'); + }); + }); + + describe('bind address audit', () => { + it('warns when bound to 0.0.0.0 in non-distributed mode', () => { + const findings = auditSecurityDefaults({ resolvedBindAddress: '0.0.0.0' }, true); + const bindFinding = findings.find((f) => f.code === 'BIND_ALL_INTERFACES'); + expect(bindFinding).toBeDefined(); + expect(bindFinding!.level).toBe('warn'); + }); + + it('info when bound to 0.0.0.0 in distributed mode', () => { + const findings = auditSecurityDefaults({ resolvedBindAddress: '0.0.0.0', deploymentMode: 'distributed' }, true); + const bindFinding = findings.find((f) => f.code === 'BIND_ALL_INTERFACES_DISTRIBUTED'); + expect(bindFinding).toBeDefined(); + expect(bindFinding!.level).toBe('info'); + }); + + it('info when bound to loopback', () => { + const findings = auditSecurityDefaults({ resolvedBindAddress: '127.0.0.1' }, true); + const bindFinding = findings.find((f) => f.code === 'BIND_RESTRICTED'); + expect(bindFinding).toBeDefined(); + }); + }); + + describe('DNS rebinding audit', () => { + it('warns when DNS rebinding protection is disabled', () => { + const findings = auditSecurityDefaults({}, true); + const dnsFinding = findings.find((f) => f.code === 'DNS_REBINDING_UNPROTECTED'); + expect(dnsFinding).toBeDefined(); + expect(dnsFinding!.level).toBe('warn'); + }); + + it('info when DNS rebinding protection is enabled', () => { + const config: SecurityAuditConfig = { + security: { dnsRebindingProtection: { enabled: true } }, + }; + const findings = auditSecurityDefaults(config, true); + const dnsFinding = findings.find((f) => f.code === 'DNS_REBINDING_PROTECTED'); + expect(dnsFinding).toBeDefined(); + }); + }); + + describe('strict mode', () => { + it('shows strict mode enabled when strict is true', () => { + const config: SecurityAuditConfig = { + security: { strict: true, dnsRebindingProtection: { enabled: true } }, + }; + const findings = auditSecurityDefaults(config, true); + const strictFinding = findings.find((f) => f.code === 'STRICT_MODE_ENABLED'); + expect(strictFinding).toBeDefined(); + }); + + it('shows strict mode hint when strict is not set', () => { + const findings = auditSecurityDefaults({}, true); + const hintFinding = findings.find((f) => f.code === 'STRICT_MODE_HINT'); + expect(hintFinding).toBeDefined(); + }); + + it('does not emit CORS or DNS warnings when strict mode is enabled', () => { + const config: SecurityAuditConfig = { + security: { strict: true }, + }; + const findings = auditSecurityDefaults(config, true); + + const corsWarn = findings.find((f) => f.code === 'CORS_PERMISSIVE_DEFAULT'); + const dnsWarn = findings.find((f) => f.code === 'DNS_REBINDING_UNPROTECTED'); + expect(corsWarn).toBeUndefined(); + expect(dnsWarn).toBeUndefined(); + + const strictEnabled = findings.find((f) => f.code === 'STRICT_MODE_ENABLED'); + expect(strictEnabled).toBeDefined(); + }); + }); +}); + +describe('logSecurityFindings()', () => { + it('logs warnings and info messages', () => { + const logger = { info: jest.fn(), warn: jest.fn() }; + const findings: SecurityFinding[] = [ + { level: 'warn', code: 'TEST_WARN', message: 'test warning' }, + { level: 'info', code: 'TEST_INFO', message: 'test info' }, + ]; + + logSecurityFindings(findings, logger); + + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.info).toHaveBeenCalledTimes(1); + }); + + it('includes recommendation when present', () => { + const logger = { info: jest.fn(), warn: jest.fn() }; + const findings: SecurityFinding[] = [{ level: 'warn', code: 'TEST', message: 'msg', recommendation: 'fix it' }]; + + logSecurityFindings(findings, logger); + + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('fix it')); + }); + + it('does nothing with empty findings', () => { + const logger = { info: jest.fn(), warn: jest.fn() }; + logSecurityFindings([], logger); + + expect(logger.info).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + }); +}); + +describe('resolveBindAddress()', () => { + it('returns 0.0.0.0 by default (backwards compatible)', () => { + expect(resolveBindAddress()).toBe('0.0.0.0'); + }); + + it('returns loopback when strict in standalone mode', () => { + expect(resolveBindAddress({ strict: true }, 'standalone')).toBe('127.0.0.1'); + }); + + it('returns 0.0.0.0 when strict in distributed mode', () => { + expect(resolveBindAddress({ strict: true }, 'distributed')).toBe('0.0.0.0'); + }); + + it('resolves loopback keyword', () => { + expect(resolveBindAddress({ bindAddress: 'loopback' })).toBe('127.0.0.1'); + }); + + it('resolves all keyword', () => { + expect(resolveBindAddress({ bindAddress: 'all' })).toBe('0.0.0.0'); + }); + + it('passes through specific IP', () => { + expect(resolveBindAddress({ bindAddress: '192.168.1.1' })).toBe('192.168.1.1'); + }); + + it('explicit bindAddress takes priority over strict', () => { + expect(resolveBindAddress({ strict: true, bindAddress: 'all' }, 'standalone')).toBe('0.0.0.0'); + }); +}); diff --git a/libs/sdk/src/server/security/security-audit.ts b/libs/sdk/src/server/security/security-audit.ts new file mode 100644 index 000000000..380d8ad54 --- /dev/null +++ b/libs/sdk/src/server/security/security-audit.ts @@ -0,0 +1,203 @@ +/** + * Security Audit — Production Readiness Warnings + * + * Logs security-relevant configuration at server startup. + * Warn-only approach: no defaults are changed, but insecure + * configurations are flagged in production environments. + */ + +import type { CorsOptions } from '../../common'; + +/** + * Security configuration for the audit. + */ +export interface SecurityAuditConfig { + /** CORS configuration (undefined = permissive default) */ + cors?: CorsOptions | false; + /** Security options from HttpOptionsInterface */ + security?: { + strict?: boolean; + bindAddress?: 'loopback' | 'all' | string; + dnsRebindingProtection?: { + enabled?: boolean; + allowedHosts?: string[]; + allowedOrigins?: string[]; + }; + }; + /** Resolved bind address (what the server actually binds to) */ + resolvedBindAddress?: string; + /** Deployment mode */ + deploymentMode?: string; +} + +/** + * Individual security finding. + */ +export interface SecurityFinding { + level: 'warn' | 'info'; + code: string; + message: string; + recommendation?: string; +} + +/** + * Audit security configuration and return findings. + * Called at server startup to produce log warnings. + * + * @param config - Current security-relevant configuration + * @param isProduction - Whether NODE_ENV is 'production' + * @returns Array of security findings + */ +export function auditSecurityDefaults(config: SecurityAuditConfig, isProduction: boolean): SecurityFinding[] { + const findings: SecurityFinding[] = []; + + // Only audit in production or distributed mode + if (!isProduction && config.deploymentMode !== 'distributed') { + return findings; + } + + const strict = config.security?.strict === true; + + // CORS audit — suppress raw-config warnings when strict mode handles it + if (config.cors === undefined && !strict) { + findings.push({ + level: 'warn', + code: 'CORS_PERMISSIVE_DEFAULT', + message: 'CORS is using the permissive default (origin: true), which allows all origins.', + recommendation: 'Set explicit cors.origin to restrict allowed origins in production.', + }); + } else if (config.cors !== false && config.cors?.origin === true && !strict) { + findings.push({ + level: 'warn', + code: 'CORS_ORIGIN_TRUE', + message: 'CORS origin=true allows all origins to make cross-origin requests.', + recommendation: 'Set cors.origin to specific allowed origins.', + }); + } else if (config.cors === false) { + findings.push({ + level: 'info', + code: 'CORS_DISABLED', + message: 'CORS is disabled. Cross-origin requests will be blocked by browsers.', + }); + } else { + findings.push({ + level: 'info', + code: 'CORS_CONFIGURED', + message: 'CORS is explicitly configured.', + }); + } + + // Bind address audit + const bindAddress = config.resolvedBindAddress ?? '0.0.0.0'; + if (bindAddress === '0.0.0.0' || bindAddress === '::') { + if (config.deploymentMode !== 'distributed') { + findings.push({ + level: 'warn', + code: 'BIND_ALL_INTERFACES', + message: `Server bound to ${bindAddress} — accessible from all network interfaces.`, + recommendation: + "Set security.bindAddress to 'loopback' or '127.0.0.1' for local-only access, " + + "or configure a reverse proxy. Distributed deployments require 'all' (0.0.0.0).", + }); + } else { + findings.push({ + level: 'info', + code: 'BIND_ALL_INTERFACES_DISTRIBUTED', + message: `Server bound to ${bindAddress} (expected for distributed deployment).`, + }); + } + } else { + findings.push({ + level: 'info', + code: 'BIND_RESTRICTED', + message: `Server bound to ${bindAddress}.`, + }); + } + + // DNS rebinding protection audit — strict mode implies protection is enabled + const dnsProtectionEnabled = config.security?.dnsRebindingProtection?.enabled ?? strict; + if (!dnsProtectionEnabled) { + findings.push({ + level: 'warn', + code: 'DNS_REBINDING_UNPROTECTED', + message: 'DNS rebinding protection is disabled.', + recommendation: 'Enable security.dnsRebindingProtection with allowedHosts to prevent DNS rebinding attacks.', + }); + } else { + findings.push({ + level: 'info', + code: 'DNS_REBINDING_PROTECTED', + message: 'DNS rebinding protection is enabled.', + }); + } + + // Strict mode info + if (config.security?.strict) { + findings.push({ + level: 'info', + code: 'STRICT_MODE_ENABLED', + message: 'Strict security mode is enabled: loopback binding, restrictive CORS, DNS rebinding protection.', + }); + } else { + findings.push({ + level: 'info', + code: 'STRICT_MODE_HINT', + message: 'To enable strict security defaults, set security.strict = true in HttpOptions.', + }); + } + + return findings; +} + +/** + * Format and log security findings. + * + * @param findings - Security findings from auditSecurityDefaults + * @param logger - Logger with info/warn methods + */ +export function logSecurityFindings( + findings: SecurityFinding[], + logger: { + info: (msg: string) => void; + warn: (msg: string) => void; + }, +): void { + if (findings.length === 0) return; + + for (const finding of findings) { + const prefix = `[Security] ${finding.code}:`; + const message = finding.recommendation + ? `${prefix} ${finding.message} ${finding.recommendation}` + : `${prefix} ${finding.message}`; + + if (finding.level === 'warn') { + logger.warn(message); + } else { + logger.info(message); + } + } +} + +/** + * Resolve the effective bind address based on configuration and deployment mode. + * + * @param security - Security configuration + * @param deploymentMode - Current deployment mode + * @returns Resolved IP address string + */ +export function resolveBindAddress(security?: SecurityAuditConfig['security'], deploymentMode?: string): string { + // Explicit bind address takes priority + if (security?.bindAddress) { + if (security.bindAddress === 'loopback') return '127.0.0.1'; + if (security.bindAddress === 'all') return '0.0.0.0'; + return security.bindAddress; + } + + // Strict mode: loopback for standalone, all for distributed + if (security?.strict) { + return deploymentMode === 'distributed' ? '0.0.0.0' : '127.0.0.1'; + } + + // Default: 0.0.0.0 (backwards compatible — no breaking changes) + return '0.0.0.0'; +} diff --git a/libs/sdk/src/server/server.instance.ts b/libs/sdk/src/server/server.instance.ts index 38c1ef393..2b5028bf8 100644 --- a/libs/sdk/src/server/server.instance.ts +++ b/libs/sdk/src/server/server.instance.ts @@ -1,9 +1,18 @@ -import { FrontMcpServer, HttpOptions, HttpMethod, ServerRequestHandler, CorsOptions } from '../common'; +import { getRuntimeContext } from '@frontmcp/utils'; + import { ExpressHostAdapter } from '#express-host'; -import { HostServerAdapter } from './adapters/base.host.adapter'; -import type { HealthService } from '../health'; -import type { HealthOptionsInterface } from '../common/types/options/health'; -import { registerHealthRoutes } from '../health'; + +import { + FrontMcpServer, + type CorsOptions, + type HttpMethod, + type HttpOptions, + type ServerRequestHandler, +} from '../common'; +import { type HealthOptionsInterface } from '../common/types/options/health'; +import { registerHealthRoutes, type HealthService } from '../health'; +import { type HostServerAdapter } from './adapters/base.host.adapter'; +import { auditSecurityDefaults, logSecurityFindings, resolveBindAddress } from './security/security-audit'; const DEFAULT_CORS: CorsOptions = { origin: true, credentials: false }; @@ -28,7 +37,10 @@ export class FrontMcpServerInstance extends FrontMcpServer { this.host = this.config.hostFactory; } else { const corsConfig = this.config.cors === false ? undefined : (this.config.cors ?? DEFAULT_CORS); - this.host = new ExpressHostAdapter(corsConfig ? { cors: corsConfig } : undefined); + this.host = new ExpressHostAdapter({ + ...(corsConfig ? { cors: corsConfig } : {}), + ...(this.config.security ? { security: this.config.security } : {}), + }); } } @@ -85,6 +97,23 @@ export class FrontMcpServerInstance extends FrontMcpServer { async start() { this.prepare(); - await this.host.start(this.config.socketPath ?? this.config.port); + + const deploymentMode = getRuntimeContext().deployment; + const bindAddress = resolveBindAddress(this.config.security, deploymentMode); + + // Run security audit (warns in production/distributed mode) + const isProduction = process.env['NODE_ENV'] === 'production'; + const findings = auditSecurityDefaults( + { + cors: this.config.cors, + security: this.config.security, + resolvedBindAddress: bindAddress, + deploymentMode, + }, + isProduction, + ); + logSecurityFindings(findings, console); + + await this.host.start(this.config.socketPath ?? this.config.port, bindAddress); } } diff --git a/libs/sdk/src/transport/__tests__/transport.registry.spec.ts b/libs/sdk/src/transport/__tests__/transport.registry.spec.ts index f559d1f2f..dbc512e29 100644 --- a/libs/sdk/src/transport/__tests__/transport.registry.spec.ts +++ b/libs/sdk/src/transport/__tests__/transport.registry.spec.ts @@ -4,6 +4,7 @@ * Tests for the TransportService which manages transport sessions and their lifecycle. */ import { createHash } from 'crypto'; + import { TransportService } from '../transport.registry'; // Mock dependencies @@ -309,8 +310,8 @@ describe('TransportService', () => { ); }); - it('should return undefined for non-streamable-http types', async () => { - const session = await service.getStoredSession('sse', 'test-token', 'session-123'); + it('should return undefined for non-streamable-http/sse types', async () => { + const session = await service.getStoredSession('stdio', 'test-token', 'session-123'); expect(session).toBeUndefined(); expect(mockRedisSessionStore.get).not.toHaveBeenCalled(); }); diff --git a/libs/sdk/src/transport/bus/__tests__/redis-transport-bus.spec.ts b/libs/sdk/src/transport/bus/__tests__/redis-transport-bus.spec.ts new file mode 100644 index 000000000..bdf872127 --- /dev/null +++ b/libs/sdk/src/transport/bus/__tests__/redis-transport-bus.spec.ts @@ -0,0 +1,184 @@ +import { MethodNotImplementedError } from '../../../errors/transport.errors'; +import type { TransportBus, TransportKey } from '../../transport.types'; +import { RedisTransportBus, type BusRedisClient } from '../redis-transport-bus'; + +function createMockRedis(): jest.Mocked { + return { + hset: jest.fn().mockResolvedValue(1), + hdel: jest.fn().mockResolvedValue(1), + hget: jest.fn().mockResolvedValue(null), + expire: jest.fn().mockResolvedValue(1), + del: jest.fn().mockResolvedValue(1), + publish: jest.fn().mockResolvedValue(1), + eval: jest.fn().mockResolvedValue(1), + }; +} + +function createKey(overrides?: Partial): TransportKey { + return { + type: 'streamable-http', + token: 'test-token', + tokenHash: 'abc123hash', + sessionId: 'session-001', + ...overrides, + }; +} + +describe('RedisTransportBus', () => { + let redis: jest.Mocked; + let bus: RedisTransportBus; + + beforeEach(() => { + redis = createMockRedis(); + bus = new RedisTransportBus(redis, 'node-1'); + }); + + describe('nodeId()', () => { + it('returns the machine ID passed to constructor', () => { + expect(bus.nodeId()).toBe('node-1'); + }); + }); + + describe('advertise()', () => { + it('stores nodeId and channel in Redis Hash with TTL', async () => { + const key = createKey(); + await bus.advertise(key); + + const expectedRedisKey = `mcp:bus:streamable-http:abc123hash:session-001`; + expect(redis.hset).toHaveBeenCalledWith(expectedRedisKey, 'nodeId', 'node-1'); + expect(redis.hset).toHaveBeenCalledWith(expectedRedisKey, 'channel', 'mcp:ha:notify:node-1'); + expect(redis.expire).toHaveBeenCalledWith(expectedRedisKey, 3600); + }); + + it('uses custom key prefix and TTL', async () => { + bus = new RedisTransportBus(redis, 'node-2', { + keyPrefix: 'custom:bus:', + ttlSeconds: 7200, + haKeyPrefix: 'custom:ha:', + }); + + const key = createKey(); + await bus.advertise(key); + + const expectedRedisKey = 'custom:bus:streamable-http:abc123hash:session-001'; + expect(redis.hset).toHaveBeenCalledWith(expectedRedisKey, 'nodeId', 'node-2'); + expect(redis.hset).toHaveBeenCalledWith(expectedRedisKey, 'channel', 'custom:ha:notify:node-2'); + expect(redis.expire).toHaveBeenCalledWith(expectedRedisKey, 7200); + }); + }); + + describe('revoke()', () => { + it('uses atomic CAS to delete only if we own the key', async () => { + const key = createKey(); + await bus.revoke(key); + + expect(redis.eval).toHaveBeenCalledWith( + expect.stringMatching(/HGET.*DEL/s), + 1, + 'mcp:bus:streamable-http:abc123hash:session-001', + 'node-1', + ); + }); + }); + + describe('lookup()', () => { + it('returns null when session not registered', async () => { + const result = await bus.lookup(createKey()); + expect(result).toBeNull(); + }); + + it('returns null when the session is owned by this node', async () => { + redis.hget.mockImplementation(async (_key: string, field: string) => { + if (field === 'nodeId') return 'node-1'; + if (field === 'channel') return 'mcp:ha:notify:node-1'; + return null; + }); + + const result = await bus.lookup(createKey()); + expect(result).toBeNull(); + }); + + it('returns remote location when session is owned by another node', async () => { + redis.hget.mockImplementation(async (_key: string, field: string) => { + if (field === 'nodeId') return 'node-2'; + if (field === 'channel') return 'mcp:ha:notify:node-2'; + return null; + }); + + const result = await bus.lookup(createKey()); + expect(result).toEqual({ nodeId: 'node-2', channel: 'mcp:ha:notify:node-2' }); + }); + + it('returns null when channel is missing', async () => { + redis.hget.mockImplementation(async (_key: string, field: string) => { + if (field === 'nodeId') return 'node-2'; + return null; + }); + + const result = await bus.lookup(createKey()); + expect(result).toBeNull(); + }); + }); + + describe('proxyRequest()', () => { + it('throws MethodNotImplementedError', async () => { + const busCasted = bus as TransportBus; + await expect( + busCasted.proxyRequest( + createKey(), + {}, + { onResponseStart: jest.fn(), onResponseChunk: jest.fn(), onResponseEnd: jest.fn() }, + ), + ).rejects.toThrow(MethodNotImplementedError); + }); + }); + + describe('destroyRemote()', () => { + it('publishes destroy command to the owning pod channel', async () => { + redis.hget.mockImplementation(async (_key: string, field: string) => { + if (field === 'nodeId') return 'node-2'; + if (field === 'channel') return 'mcp:ha:notify:node-2'; + return null; + }); + + await bus.destroyRemote(createKey(), 'session terminated'); + + expect(redis.publish).toHaveBeenCalledWith( + 'mcp:ha:notify:node-2', + expect.stringContaining('"kind":"destroy-session"'), + ); + // Should NOT call del — let the owning node revoke after destroy + expect(redis.del).not.toHaveBeenCalled(); + }); + + it('does nothing when session is owned by this node', async () => { + redis.hget.mockImplementation(async (_key: string, field: string) => { + if (field === 'nodeId') return 'node-1'; + return null; + }); + + await bus.destroyRemote(createKey(), 'test'); + + expect(redis.publish).not.toHaveBeenCalled(); + }); + + it('does nothing when session not found', async () => { + await bus.destroyRemote(createKey(), 'test'); + + expect(redis.publish).not.toHaveBeenCalled(); + }); + }); + + describe('logger integration', () => { + it('logs debug messages when logger is provided', async () => { + const logger = { info: jest.fn(), warn: jest.fn(), debug: jest.fn() }; + bus = new RedisTransportBus(redis, 'node-1', { logger }); + + await bus.advertise(createKey()); + expect(logger.debug).toHaveBeenCalledWith( + '[TransportBus] Advertised session', + expect.objectContaining({ nodeId: 'node-1' }), + ); + }); + }); +}); diff --git a/libs/sdk/src/transport/bus/index.ts b/libs/sdk/src/transport/bus/index.ts new file mode 100644 index 000000000..3600798a1 --- /dev/null +++ b/libs/sdk/src/transport/bus/index.ts @@ -0,0 +1,2 @@ +export { RedisTransportBus } from './redis-transport-bus'; +export type { BusRedisClient, RedisTransportBusOptions } from './redis-transport-bus'; diff --git a/libs/sdk/src/transport/bus/redis-transport-bus.ts b/libs/sdk/src/transport/bus/redis-transport-bus.ts new file mode 100644 index 000000000..f3399bf4c --- /dev/null +++ b/libs/sdk/src/transport/bus/redis-transport-bus.ts @@ -0,0 +1,196 @@ +/** + * Redis Transport Bus — Distributed Session Location Registry + * + * Maps sessions to the pods that own them using Redis Hash keys. + * Used by TransportService in distributed mode to discover which + * pod owns a given session and (optionally) relay operations to it. + */ + +import { MethodNotImplementedError } from '../../errors/transport.errors'; +import type { RemoteLocation, TransportBus, TransportKey } from '../transport.types'; + +/** + * Minimal Redis client interface for the transport bus. + * Subset of ioredis — allows plugging any compatible client. + */ +export interface BusRedisClient { + hset(key: string, field: string, value: string): Promise; + hdel(key: string, ...fields: string[]): Promise; + hget(key: string, field: string): Promise; + expire(key: string, seconds: number): Promise; + del(key: string): Promise; + publish(channel: string, message: string): Promise; + eval(script: string, numkeys: number, ...args: (string | number)[]): Promise; +} + +/** Default key prefix for bus keys. */ +const DEFAULT_BUS_PREFIX = 'mcp:bus:'; + +/** Default TTL for bus entries (seconds). Matches session default of 1 hour. */ +const DEFAULT_BUS_TTL_SECONDS = 3600; + +/** + * Lua CAS script for atomic revoke: only delete if nodeId still matches. + * KEYS[1] = bus key, ARGV[1] = expected nodeId + * Returns 1 if deleted, 0 if owned by another node or not found. + */ +const REVOKE_LUA = ` +local nodeId = redis.call('HGET', KEYS[1], 'nodeId') +if nodeId == ARGV[1] then + redis.call('DEL', KEYS[1]) + return 1 +end +return 0 +`; + +/** + * Configuration options for RedisTransportBus. + */ +export interface RedisTransportBusOptions { + /** Redis key prefix. @default 'mcp:bus:' */ + keyPrefix?: string; + /** TTL for bus entries in seconds. @default 3600 */ + ttlSeconds?: number; + /** HA relay key prefix for destroy commands. @default 'mcp:ha:' */ + haKeyPrefix?: string; + /** Logger (optional) */ + logger?: { + info: (msg: string, meta?: Record) => void; + warn: (msg: string, meta?: Record) => void; + debug: (msg: string, meta?: Record) => void; + }; +} + +/** + * Redis-backed TransportBus implementation. + * + * For each session, stores a Hash with nodeId and channel fields. + * The bus provides session-to-node mapping for distributed lookups + * and destroy-remote via pub/sub relay. + * + * Note: `proxyRequest()` is deferred — session recreation via + * TransportService.recreateTransporter() handles cross-pod requests. + */ +export class RedisTransportBus implements TransportBus { + private readonly keyPrefix: string; + private readonly ttlSeconds: number; + private readonly haKeyPrefix: string; + private readonly logger?: RedisTransportBusOptions['logger']; + + constructor( + private readonly redis: BusRedisClient, + private readonly machineId: string, + options?: RedisTransportBusOptions, + ) { + this.keyPrefix = options?.keyPrefix ?? DEFAULT_BUS_PREFIX; + this.ttlSeconds = options?.ttlSeconds ?? DEFAULT_BUS_TTL_SECONDS; + this.haKeyPrefix = options?.haKeyPrefix ?? 'mcp:ha:'; + this.logger = options?.logger; + } + + nodeId(): string { + return this.machineId; + } + + /** + * Advertise that this node owns a session. + * Stores the nodeId and relay channel in a Redis Hash. + */ + async advertise(key: TransportKey): Promise { + const redisKey = this.busKey(key); + const channel = `${this.haKeyPrefix}notify:${this.machineId}`; + + await this.redis.hset(redisKey, 'nodeId', this.machineId); + await this.redis.hset(redisKey, 'channel', channel); + await this.redis.expire(redisKey, this.ttlSeconds); + + this.logger?.debug('[TransportBus] Advertised session', { + sessionId: key.sessionId.slice(0, 20), + nodeId: this.machineId, + }); + } + + /** + * Revoke ownership of a session (e.g., on transport dispose). + * Uses atomic compare-and-delete to avoid removing a newer owner's registration. + */ + async revoke(key: TransportKey): Promise { + const redisKey = this.busKey(key); + + // Atomic CAS: only delete if we still own this session + const result = await this.redis.eval(REVOKE_LUA, 1, redisKey, this.machineId); + + this.logger?.debug('[TransportBus] Revoked session', { + sessionId: key.sessionId.slice(0, 20), + deleted: result === 1, + }); + } + + /** + * Look up which node owns a session. + * Returns null if the session is not registered in the bus. + */ + async lookup(key: TransportKey): Promise { + const redisKey = this.busKey(key); + + const nodeId = await this.redis.hget(redisKey, 'nodeId'); + if (!nodeId) return null; + + // Skip if we own this session — caller should use local transport + if (nodeId === this.machineId) return null; + + const channel = await this.redis.hget(redisKey, 'channel'); + if (!channel) return null; + + return { nodeId, channel }; + } + + /** + * Proxy a request to the owning node. + * + * Phase 1: Not implemented — TransportService.recreateTransporter() + * handles cross-pod session access via Redis session store. + */ + async proxyRequest(): Promise { + throw new MethodNotImplementedError('RedisTransportBus', 'proxyRequest'); + } + + /** + * Destroy a session on a remote node via pub/sub relay. + */ + async destroyRemote(key: TransportKey, reason?: string): Promise { + const redisKey = this.busKey(key); + const nodeId = await this.redis.hget(redisKey, 'nodeId'); + + if (!nodeId || nodeId === this.machineId) return; + + const channel = await this.redis.hget(redisKey, 'channel'); + if (!channel) return; + + const message = JSON.stringify({ + kind: 'destroy-session', + sessionId: key.sessionId, + reason, + sourceNodeId: this.machineId, + timestamp: Date.now(), + }); + + await this.redis.publish(channel, message); + + // Let the owning node revoke after it destroys the transport. + // Don't blindly delete — another node may have already taken ownership. + + this.logger?.info('[TransportBus] Sent destroy-remote', { + sessionId: key.sessionId.slice(0, 20), + targetNodeId: nodeId, + }); + } + + /** + * Build the Redis key for a session in the bus. + * Format: {prefix}{type}:{tokenHash}:{sessionId} + */ + private busKey(key: TransportKey): string { + return `${this.keyPrefix}${key.type}:${key.tokenHash}:${key.sessionId}`; + } +} diff --git a/libs/sdk/src/transport/flows/handle.sse.flow.ts b/libs/sdk/src/transport/flows/handle.sse.flow.ts index f94b089e8..30fe16fee 100644 --- a/libs/sdk/src/transport/flows/handle.sse.flow.ts +++ b/libs/sdk/src/transport/flows/handle.sse.flow.ts @@ -1,21 +1,25 @@ +import { z } from 'zod'; + +import { buildSetCookie, getMachineId, getRuntimeContext } from '@frontmcp/utils'; + +import { createSessionId } from '../../auth/session/utils/session-id.utils'; import { Flow, - httpInputSchema, - FlowRunOptions, - httpOutputSchema, - FlowPlan, FlowBase, FlowHooksOf, + httpInputSchema, + httpOutputSchema, httpRespond, - ServerRequestTokens, - Authorization, normalizeEntryPrefix, normalizeScopeBase, + ServerRequestTokens, validateMcpSessionHeader, + type Authorization, + type FlowPlan, + type FlowRunOptions, } from '../../common'; -import { z } from 'zod'; import { TransportServiceNotAvailableError } from '../../errors'; -import { createSessionId } from '../../auth/session/utils/session-id.utils'; +import { DEFAULT_FRONTMCP_MACHINE_ID_HEADER, DEFAULT_FRONTMCP_NODE_COOKIE } from '../../ha/ha.constants'; import { detectSkillsOnlyMode } from '../../skill/skill-mode.utils'; export const plan = { @@ -180,6 +184,19 @@ export default class HandleSseFlow extends FlowBase { const { request, response } = this.rawInput; const { token, session } = this.state.required; const transport = await transportService.createTransporter('sse', token, session.id, response); + + // Set LB affinity headers in distributed mode + if (getRuntimeContext().deployment === 'distributed') { + const nodeId = getMachineId(); + response.setHeader(DEFAULT_FRONTMCP_MACHINE_ID_HEADER, nodeId); + const cookie = buildSetCookie({ name: DEFAULT_FRONTMCP_NODE_COOKIE, value: nodeId }, request); + if (cookie) { + const existing = response.getHeader('Set-Cookie'); + const existingArr = Array.isArray(existing) ? existing : existing ? [String(existing)] : []; + response.setHeader('Set-Cookie', [...existingArr, cookie]); + } + } + await transport.initialize(request, response); this.handled(); } @@ -209,10 +226,49 @@ export default class HandleSseFlow extends FlowBase { const { request, response } = this.rawInput; const { token, session } = this.state.required; + + // 1. Check local memory first const transport = await transportService.getTransporter('sse', token, session.id); + + // 2. If not in memory but in distributed mode, check if the session exists on another pod + // SSE sessions can't be "recreated" like streamable-http (the SSE response stream is + // tied to the original HTTP connection), but we can relay the message to the owning pod. + if (!transport && getRuntimeContext().deployment === 'distributed') { + const storedSession = await transportService.getStoredSession('sse', token, session.id); + if (storedSession) { + // Session exists on another pod — relay via notification relay if available + const haManager = this.scope.haManager; + const relay = haManager?.getRelay(); + if (relay && storedSession.session.nodeId && storedSession.session.nodeId !== getMachineId()) { + const isAlive = await haManager!.isNodeAlive(storedSession.session.nodeId); + if (isAlive) { + try { + const body = request.body as Record | undefined; + await relay.publish(storedSession.session.nodeId, session.id, { + method: 'sse:relay-message', + params: { jsonRpcMessage: body }, + }); + logger.info('Relayed SSE message to owning pod', { + sessionId: session.id?.slice(0, 20), + targetNodeId: storedSession.session.nodeId, + }); + response.status(202).json({ jsonrpc: '2.0', result: {} }); + this.handled(); + return; + } catch (err) { + logger.warn('Failed to relay SSE message', { + sessionId: session.id?.slice(0, 20), + error: err instanceof Error ? err.message : String(err), + }); + } + } + } + } + } + if (!transport) { // Check if session was ever created to differentiate error types per MCP Spec 2025-11-25 - const wasCreated = transportService.wasSessionCreated('sse', token, session.id); + const wasCreated = await transportService.wasSessionCreatedAsync('sse', token, session.id); const body = request.body as Record | undefined; if (wasCreated) { diff --git a/libs/sdk/src/transport/transport.registry.ts b/libs/sdk/src/transport/transport.registry.ts index eb6f1a6a9..162fdf4a2 100644 --- a/libs/sdk/src/transport/transport.registry.ts +++ b/libs/sdk/src/transport/transport.registry.ts @@ -5,11 +5,7 @@ import { getMachineId, sha256Hex } from '@frontmcp/utils'; import { createSessionStore } from '../auth/session/session-store.factory'; import type { ServerResponse, TransportPersistenceConfigInput } from '../common'; import type { RedisOptions } from '../common/types/options/redis'; -import { - InvalidTransportSessionError, - SessionClaimConflictError, - TransportBusRequiredError, -} from '../errors/transport.errors'; +import { InvalidTransportSessionError, SessionClaimConflictError } from '../errors/transport.errors'; import type { ClientCapabilities } from '../notification/notification.service'; import type { Scope } from '../scope'; import HandleSseFlow from './flows/handle.sse.flow'; @@ -91,14 +87,11 @@ export class TransportService { return typeof this.persistenceConfig === 'object' ? this.persistenceConfig?.defaultTtlMs : undefined; } - constructor(scope: Scope, persistenceConfig?: false | TransportPersistenceConfigInput) { + constructor(scope: Scope, persistenceConfig?: false | TransportPersistenceConfigInput, bus?: TransportBus) { this.scope = scope; this.persistenceConfig = persistenceConfig; - this.distributed = false; // get from scope metadata - this.bus = undefined; // get from scope metadata - if (this.distributed && !this.bus) { - throw new TransportBusRequiredError(); - } + this.distributed = !!bus; + this.bus = bus; // Initialize session store if persistence is enabled (Redis or Vercel KV) // Simplified format: false = disabled, object with redis = enabled, undefined = not configured @@ -242,7 +235,7 @@ export class TransportService { warnOnFingerprintMismatch?: boolean; }, ): Promise { - if (!this.sessionStore || type !== 'streamable-http') return undefined; + if (!this.sessionStore || (type !== 'streamable-http' && type !== 'sse')) return undefined; const tokenHash = this.sha256(token); const stored = await this.sessionStore.get(sessionId); @@ -483,13 +476,13 @@ export class TransportService { this.insertLocal(key, transporter); - // Persist session to Redis (streamable-http only for now) - if (sessionStore && type === 'streamable-http') { + // Persist session to Redis (streamable-http and sse) + if (sessionStore && (type === 'streamable-http' || type === 'sse')) { const storedSession: StoredSession = { session: { id: sessionId, authorizationId: key.tokenHash, - protocol: 'streamable-http', + protocol: type === 'sse' ? 'sse' : 'streamable-http', createdAt: Date.now(), nodeId: getMachineId(), }, @@ -661,7 +654,7 @@ export class TransportService { // Check Redis if available - use getStoredSession() to verify token hash // (sessionStore.exists() would leak session existence to unauthorized callers) - if (this.sessionStore && type === 'streamable-http') { + if (this.sessionStore && (type === 'streamable-http' || type === 'sse')) { const stored = await this.getStoredSession(type, token, sessionId); return stored !== undefined; } diff --git a/libs/skills/catalog/frontmcp-development/references/create-tool.md b/libs/skills/catalog/frontmcp-development/references/create-tool.md index 784394382..d6a570836 100644 --- a/libs/skills/catalog/frontmcp-development/references/create-tool.md +++ b/libs/skills/catalog/frontmcp-development/references/create-tool.md @@ -34,9 +34,10 @@ Tools are the primary way to expose executable actions to AI clients in the MCP Create a class extending `ToolContext` and implement the `execute(input: In): Promise` method. The `@Tool` decorator requires at minimum a `name` and an `inputSchema`. ```typescript -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + @Tool({ name: 'greet_user', description: 'Greet a user by name', @@ -177,9 +178,8 @@ const outputSchema = { inputSchema, outputSchema, }) -// Wire generics: ToolContext -// This makes execute() return type and respond() argument fully typed -class GetWeatherTool extends ToolContext { +// No generics needed — ToolContext infers types from the @Tool decorator +class GetWeatherTool extends ToolContext { async execute(input: { city: string }) { // Return type is inferred as { temperature: number; unit: 'celsius' | 'fahrenheit' } // TypeScript will error if you return the wrong shape @@ -323,7 +323,7 @@ class DeleteRecordTool extends ToolContext { For MCP-specific errors, use error classes with JSON-RPC codes: ```typescript -import { ResourceNotFoundError, PublicMcpError, MCP_ERROR_CODES } from '@frontmcp/sdk'; +import { MCP_ERROR_CODES, PublicMcpError, ResourceNotFoundError } from '@frontmcp/sdk'; this.fail(new ResourceNotFoundError(`Record ${input.id}`)); ``` @@ -408,9 +408,10 @@ Annotation fields: For simple tools that do not need a class, use the `tool()` function builder. It returns a value you register the same way as a class tool. ```typescript -import { tool } from '@frontmcp/sdk'; import { z } from 'zod'; +import { tool } from '@frontmcp/sdk'; + const AddNumbers = tool({ name: 'add_numbers', description: 'Add two numbers', @@ -455,7 +456,7 @@ Both return values that can be registered in `tools: [RemoteTool, CloudTool]`. Add tool classes (or function-style tools) to the `tools` array in `@FrontMcp` or `@App`. ```typescript -import { FrontMcp, App } from '@frontmcp/sdk'; +import { App, FrontMcp } from '@frontmcp/sdk'; @App({ name: 'my-app', @@ -678,13 +679,14 @@ class ConvertCurrencyTool extends ToolContext { ## Common Patterns -| Pattern | Correct | Incorrect | Why | -| -------------- | ----------------------------------------------- | --------------------------------------------- | ------------------------------------------------------------- | -| Input schema | `inputSchema: { name: z.string() }` (raw shape) | `inputSchema: z.object({ name: z.string() })` | Framework wraps in `z.object()` internally | -| Output schema | Always define `outputSchema` | Omit `outputSchema` | Prevents data leaks and enables CodeCall chaining | -| DI resolution | `this.get(TOKEN)` with proper error handling | `this.tryGet(TOKEN)!` with non-null assertion | `get` throws a clear error; non-null assertions mask failures | -| Error handling | `this.fail(new ResourceNotFoundError(...))` | `throw new Error(...)` | `this.fail` triggers the error flow with MCP error codes | -| Tool naming | `snake_case` names: `get_weather` | `camelCase` or `PascalCase`: `getWeather` | MCP protocol convention for tool names | +| Pattern | Correct | Incorrect | Why | +| -------------------- | ----------------------------------------------- | ------------------------------------------------------ | -------------------------------------------------------------------------------- | +| Input schema | `inputSchema: { name: z.string() }` (raw shape) | `inputSchema: z.object({ name: z.string() })` | Framework wraps in `z.object()` internally | +| Output schema | Always define `outputSchema` | Omit `outputSchema` | Prevents data leaks and enables CodeCall chaining | +| DI resolution | `this.get(TOKEN)` with proper error handling | `this.tryGet(TOKEN)!` with non-null assertion | `get` throws a clear error; non-null assertions mask failures | +| Error handling | `this.fail(new ResourceNotFoundError(...))` | `throw new Error(...)` | `this.fail` triggers the error flow with MCP error codes | +| Tool naming | `snake_case` names: `get_weather` | `camelCase` or `PascalCase`: `getWeather` | MCP protocol convention for tool names | +| ToolContext generics | `class MyTool extends ToolContext` | `class MyTool extends ToolContext` | Types are auto-inferred from `@Tool` decorator — explicit generics are redundant | ## Verification Checklist diff --git a/libs/skills/catalog/frontmcp-observability/examples/telemetry-api/tool-custom-spans.md b/libs/skills/catalog/frontmcp-observability/examples/telemetry-api/tool-custom-spans.md index 9067defaa..7d32393b1 100644 --- a/libs/skills/catalog/frontmcp-observability/examples/telemetry-api/tool-custom-spans.md +++ b/libs/skills/catalog/frontmcp-observability/examples/telemetry-api/tool-custom-spans.md @@ -19,15 +19,16 @@ Create child spans, events, and attributes inside a tool's execute method using ```typescript // src/apps/my-app/tools/weather.tool.ts -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + @Tool({ name: 'get_weather', description: 'Get weather for a city', inputSchema: { city: z.string() }, }) -export class GetWeatherTool extends ToolContext { +export class GetWeatherTool extends ToolContext { async execute({ city }: { city: string }) { // Event on the "tool get_weather" span this.telemetry.addEvent('request-received', { city }); diff --git a/libs/skills/catalog/frontmcp-observability/examples/tracing-setup/basic-tracing.md b/libs/skills/catalog/frontmcp-observability/examples/tracing-setup/basic-tracing.md index 5a1c2eaaa..ca58091d1 100644 --- a/libs/skills/catalog/frontmcp-observability/examples/tracing-setup/basic-tracing.md +++ b/libs/skills/catalog/frontmcp-observability/examples/tracing-setup/basic-tracing.md @@ -27,7 +27,9 @@ setupOTel({ serviceName: 'my-server', exporter: 'console' }); ```typescript // src/server.ts import './setup-otel'; // Must be first import + import { FrontMcp } from '@frontmcp/sdk'; + import { MyApp } from './apps/my-app'; @FrontMcp({ @@ -40,15 +42,16 @@ export default class Server {} ```typescript // src/apps/my-app/tools/hello.tool.ts -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + @Tool({ name: 'hello', description: 'Say hello', inputSchema: { name: z.string() }, }) -export class HelloTool extends ToolContext { +export class HelloTool extends ToolContext { async execute({ name }: { name: string }) { return { greeting: `Hello, ${name}!` }; } diff --git a/libs/skills/catalog/frontmcp-observability/references/telemetry-api.md b/libs/skills/catalog/frontmcp-observability/references/telemetry-api.md index 8624f3f73..25c540bc1 100644 --- a/libs/skills/catalog/frontmcp-observability/references/telemetry-api.md +++ b/libs/skills/catalog/frontmcp-observability/references/telemetry-api.md @@ -22,15 +22,16 @@ Every execution context (tools, resources, prompts, agents) gets a `this.telemet ## Usage in Tools ```typescript -import { Tool, ToolContext } from '@frontmcp/sdk'; import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/sdk'; + @Tool({ name: 'analyze_data', description: 'Analyze dataset with custom telemetry', inputSchema: { datasetId: z.string() }, }) -class AnalyzeDataTool extends ToolContext { +class AnalyzeDataTool extends ToolContext { async execute({ datasetId }: { datasetId: string }) { // Events go on the "tool analyze_data" span this.telemetry.addEvent('analysis-started', { datasetId }); diff --git a/libs/testing/src/containers/redis-container.ts b/libs/testing/src/containers/redis-container.ts new file mode 100644 index 000000000..84034d301 --- /dev/null +++ b/libs/testing/src/containers/redis-container.ts @@ -0,0 +1,55 @@ +/** + * Redis Test Container + * + * Starts a Redis container via testcontainers for distributed E2E tests. + * Skips when Docker is not available or SKIP_REDIS_TESTS=1 is set. + */ + +import { GenericContainer, type StartedTestContainer } from 'testcontainers'; + +export interface RedisContainerInfo { + /** Redis host (usually localhost) */ + host: string; + /** Mapped Redis port */ + port: number; + /** Full Redis URL: redis://host:port */ + url: string; + /** Stop the container */ + stop: () => Promise; +} + +const REDIS_IMAGE = 'redis:7-alpine'; +const REDIS_PORT = 6379; + +/** + * Start a Redis container for testing. + * + * @returns Container info with host, port, url, and stop function + * @throws When Docker is not available + */ +export async function startRedisContainer(): Promise { + const container: StartedTestContainer = await new GenericContainer(REDIS_IMAGE) + .withExposedPorts(REDIS_PORT) + .withStartupTimeout(30_000) + .start(); + + const host = container.getHost(); + const port = container.getMappedPort(REDIS_PORT); + + return { + host, + port, + url: `redis://${host}:${port}`, + stop: async () => { + await container.stop({ timeout: 30_000 }); + }, + }; +} + +/** + * Check if distributed tests should be skipped. + * Returns true if SKIP_REDIS_TESTS=1 or SKIP_DISTRIBUTED_TESTS=1. + */ +export function shouldSkipDistributedTests(): boolean { + return process.env['SKIP_REDIS_TESTS'] === '1' || process.env['SKIP_DISTRIBUTED_TESTS'] === '1'; +} diff --git a/libs/testing/src/example-tools/tool-configs.ts b/libs/testing/src/example-tools/tool-configs.ts index 881998350..6e7133b0b 100644 --- a/libs/testing/src/example-tools/tool-configs.ts +++ b/libs/testing/src/example-tools/tool-configs.ts @@ -15,7 +15,7 @@ * description: BASIC_UI_TOOL_CONFIG.description, * ui: BASIC_UI_TOOL_CONFIG.ui, * }) - * export class BasicUITool extends ToolContext { + * export class BasicUITool extends ToolContext { * async execute(input) { * return { message: `Hello, ${input.name}!`, timestamp: Date.now() }; * } diff --git a/libs/testing/src/server/distributed-test-cluster.ts b/libs/testing/src/server/distributed-test-cluster.ts new file mode 100644 index 000000000..c433a1801 --- /dev/null +++ b/libs/testing/src/server/distributed-test-cluster.ts @@ -0,0 +1,161 @@ +/** + * Distributed Test Cluster + * + * Manages multiple TestServer instances that share a single Redis, + * each with a unique MACHINE_ID. + * Used for E2E testing of session scaling, SSE routing, and takeover. + */ + +import { TestServer, type TestServerInfo } from './test-server'; + +export interface ClusterNode { + /** Node index (0-based) */ + index: number; + /** Machine ID for this node */ + machineId: string; + /** Server info (baseUrl, port) */ + info: TestServerInfo; + /** Underlying TestServer instance */ + server: TestServer; +} + +export interface DistributedClusterOptions { + /** Redis URL (e.g., redis://localhost:6379) */ + redisUrl: string; + /** Path to the server entry file (e.g., apps/e2e/demo-e2e-distributed/src/main.ts) */ + serverEntry: string; + /** E2E project name for port allocation */ + project?: string; + /** Additional environment variables for all nodes */ + env?: Record; + /** Server startup timeout in ms (default: 45000) */ + startupTimeout?: number; +} + +/** + * Manages a cluster of FrontMCP server instances for distributed testing. + * + * @example + * ```typescript + * const redis = await startRedisContainer(); + * const cluster = new DistributedTestCluster({ + * redisUrl: redis.url, + * serverEntry: 'apps/e2e/demo-e2e-distributed/src/main.ts', + * project: 'demo-e2e-distributed', + * }); + * + * const nodes = await cluster.start(2); + * // nodes[0].info.baseUrl, nodes[1].info.baseUrl + * + * await cluster.stopNode(0); // simulate pod death + * await cluster.teardown(); + * ``` + */ +export class DistributedTestCluster { + private nodes: Map = new Map(); + private readonly options: DistributedClusterOptions; + private nextIndex = 0; + + constructor(options: DistributedClusterOptions) { + this.options = options; + } + + /** + * Start N server instances with unique MACHINE_IDs sharing the same Redis. + * Can only be called once — call teardown() before starting again. + */ + async start(count: number): Promise { + if (this.nodes.size > 0) { + throw new Error('Cluster already running. Call teardown() before start().'); + } + + const results: ClusterNode[] = []; + + try { + for (let i = 0; i < count; i++) { + const node = await this.startNode(this.nextIndex++); + results.push(node); + } + return results; + } catch (error) { + // Tear down any nodes that started before the failure + await this.teardown(); + throw error; + } + } + + /** + * Start a single node at the given index. + */ + async startNode(index: number): Promise { + const machineId = `node-${index}`; + const project = this.options.project ?? 'demo-e2e-distributed'; + + // Let TestServer.start handle port reservation via reservePort(project). + // Reserved keys are spread AFTER user env to prevent override. + const server = await TestServer.start({ + command: `npx ts-node --swc -P apps/e2e/demo-e2e-distributed/tsconfig.app.json ${this.options.serverEntry}`, + project, + startupTimeout: this.options.startupTimeout ?? 45_000, + env: { + ...this.options.env, + MACHINE_ID: machineId, + REDIS_URL: this.options.redisUrl, + NODE_ENV: 'test', + }, + }); + + this.nodes.set(index, { server, machineId }); + + return { + index, + machineId, + info: server.info, + server, + }; + } + + /** + * Stop a specific node (simulate pod death). + * Removes the node from the cluster — it cannot be reused. + */ + async stopNode(index: number): Promise { + const node = this.nodes.get(index); + if (!node) return; + try { + await node.server.stop(); + } finally { + this.nodes.delete(index); + } + } + + /** + * Get info for a running node. Returns undefined if the node + * was never started or has been stopped. + */ + getNode(index: number): ClusterNode | undefined { + const node = this.nodes.get(index); + if (!node) return undefined; + return { + index, + machineId: node.machineId, + info: node.server.info, + server: node.server, + }; + } + + /** + * Stop all nodes and clean up. + */ + async teardown(): Promise { + const stops = [...this.nodes.entries()].map(async ([_index, node]) => { + try { + await node.server.stop(); + } catch { + // Best effort + } + }); + await Promise.all(stops); + this.nodes.clear(); + } +} diff --git a/libs/testing/src/server/port-registry.ts b/libs/testing/src/server/port-registry.ts index b6a08076d..07f0b73b6 100644 --- a/libs/testing/src/server/port-registry.ts +++ b/libs/testing/src/server/port-registry.ts @@ -8,7 +8,7 @@ * 3. Verification that ports are actually available before assignment */ -import { createServer, Server } from 'net'; +import { createServer, type Server } from 'net'; // ═══════════════════════════════════════════════════════════════════ // PORT RANGE CONFIGURATION @@ -63,12 +63,15 @@ export const E2E_PORT_RANGES = { 'demo-e2e-feature-flags': { start: 50380, size: 10 }, 'demo-e2e-resource-providers': { start: 50390, size: 10 }, - // ESM E2E tests (50400-50449) + // ESM E2E tests (50400-50439) 'esm-package-server': { start: 50400, size: 10 }, 'esm-package-server-hot-reload': { start: 50410, size: 10 }, 'esm-package-server-cli': { start: 50420, size: 10 }, 'demo-e2e-unix-socket': { start: 50430, size: 10 }, + // Distributed E2E tests (50440-50459) + 'demo-e2e-distributed': { start: 50440, size: 20 }, + // Mock servers and utilities (50900-50999) 'mock-oauth': { start: 50900, size: 10 }, 'mock-api': { start: 50910, size: 10 }, diff --git a/package.json b/package.json index 3501121e2..e20264a81 100644 --- a/package.json +++ b/package.json @@ -131,6 +131,7 @@ "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.0", "tailwindcss": "^4.1.18", + "testcontainers": "^11.14.0", "ts-jest": "^29.4.0", "ts-node": "10.9.1", "tslib": "^2.3.0", diff --git a/yarn.lock b/yarn.lock index a22f7f70f..15ec4744d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1042,6 +1042,11 @@ "@babel/helper-string-parser" "^7.27.1" "@babel/helper-validator-identifier" "^7.28.5" +"@balena/dockerignore@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@balena/dockerignore/-/dockerignore-1.0.2.tgz#9ffe4726915251e8eb69f44ef3547e0da2c03e0d" + integrity sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q== + "@bcoe/v8-coverage@^0.2.3": version "0.2.3" resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" @@ -1532,6 +1537,34 @@ "@eslint/core" "^0.17.0" levn "^0.4.1" +"@grpc/grpc-js@^1.11.1": + version "1.14.3" + resolved "https://registry.yarnpkg.com/@grpc/grpc-js/-/grpc-js-1.14.3.tgz#4c9b817a900ae4020ddc28515ae4b52c78cfb8da" + integrity sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA== + dependencies: + "@grpc/proto-loader" "^0.8.0" + "@js-sdsl/ordered-map" "^4.4.2" + +"@grpc/proto-loader@^0.7.13": + version "0.7.15" + resolved "https://registry.yarnpkg.com/@grpc/proto-loader/-/proto-loader-0.7.15.tgz#4cdfbf35a35461fc843abe8b9e2c0770b5095e60" + integrity sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ== + dependencies: + lodash.camelcase "^4.3.0" + long "^5.0.0" + protobufjs "^7.2.5" + yargs "^17.7.2" + +"@grpc/proto-loader@^0.8.0": + version "0.8.0" + resolved "https://registry.yarnpkg.com/@grpc/proto-loader/-/proto-loader-0.8.0.tgz#b6c324dd909c458a0e4aa9bfd3d69cf78a4b9bd8" + integrity sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ== + dependencies: + lodash.camelcase "^4.3.0" + long "^5.0.0" + protobufjs "^7.5.3" + yargs "^17.7.2" + "@hono/node-server@^1.19.9": version "1.19.9" resolved "https://registry.yarnpkg.com/@hono/node-server/-/node-server-1.19.9.tgz#8f37119b1acf283fd3f6035f3d1356fdb97a09ac" @@ -2090,6 +2123,11 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@js-sdsl/ordered-map@^4.4.2": + version "4.4.2" + resolved "https://registry.yarnpkg.com/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz#9299f82874bab9e4c7f9c48d865becbfe8d6907c" + integrity sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw== + "@jsdevtools/ono@^7.1.3": version "7.1.3" resolved "https://registry.yarnpkg.com/@jsdevtools/ono/-/ono-7.1.3.tgz#9df03bbd7c696a5c58885c34aa06da41c8543796" @@ -2255,6 +2293,13 @@ "@jsonjoy.com/buffers" "^1.0.0" "@jsonjoy.com/codegen" "^1.0.0" +"@kwsites/file-exists@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@kwsites/file-exists/-/file-exists-1.1.1.tgz#ad1efcac13e1987d8dbaf235ef3be5b0d96faa99" + integrity sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw== + dependencies: + debug "^4.1.1" + "@leichtgewicht/ip-codec@^2.0.1": version "2.0.5" resolved "https://registry.yarnpkg.com/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz#4fc56c15c580b9adb7dc3c333a134e540b44bfb1" @@ -3921,6 +3966,23 @@ dependencies: "@types/ms" "*" +"@types/docker-modem@*": + version "3.0.6" + resolved "https://registry.yarnpkg.com/@types/docker-modem/-/docker-modem-3.0.6.tgz#1f9262fcf85425b158ca725699a03eb23cddbf87" + integrity sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg== + dependencies: + "@types/node" "*" + "@types/ssh2" "*" + +"@types/dockerode@^4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@types/dockerode/-/dockerode-4.0.1.tgz#26a44995a86322b4489090efd97890a5585a63a5" + integrity sha512-cmUpB+dPN955PxBEuXE3f6lKO1hHiIGYJA46IVF3BJpNsZGvtBDcRnlrHYHtOH/B6vtDOyl2kZ2ShAu3mgc27Q== + dependencies: + "@types/docker-modem" "*" + "@types/node" "*" + "@types/ssh2" "*" + "@types/dompurify@^3.0.0": version "3.2.0" resolved "https://registry.yarnpkg.com/@types/dompurify/-/dompurify-3.2.0.tgz#56610bf3e4250df57744d61fbd95422e07dfb840" @@ -4108,6 +4170,13 @@ dependencies: undici-types "~7.16.0" +"@types/node@^18.11.18": + version "18.19.130" + resolved "https://registry.yarnpkg.com/@types/node/-/node-18.19.130.tgz#da4c6324793a79defb7a62cba3947ec5add00d59" + integrity sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg== + dependencies: + undici-types "~5.26.4" + "@types/node@^24.0.0": version "24.10.13" resolved "https://registry.yarnpkg.com/@types/node/-/node-24.10.13.tgz#2fac25c0e30f3848e19912c3b8791a28370e9e07" @@ -4215,6 +4284,28 @@ dependencies: "@types/node" "*" +"@types/ssh2-streams@*": + version "0.1.13" + resolved "https://registry.yarnpkg.com/@types/ssh2-streams/-/ssh2-streams-0.1.13.tgz#f8d34a22be50fb8dbafbb2bbc289add0d22daa51" + integrity sha512-faHyY3brO9oLEA0QlcO8N2wT7R0+1sHWZvQ+y3rMLwdY1ZyS1z0W3t65j9PqT4HmQ6ALzNe7RZlNuCNE0wBSWA== + dependencies: + "@types/node" "*" + +"@types/ssh2@*": + version "1.15.5" + resolved "https://registry.yarnpkg.com/@types/ssh2/-/ssh2-1.15.5.tgz#6d8f45db2f39519b8d9377268fa71ed77d969686" + integrity sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ== + dependencies: + "@types/node" "^18.11.18" + +"@types/ssh2@^0.5.48": + version "0.5.52" + resolved "https://registry.yarnpkg.com/@types/ssh2/-/ssh2-0.5.52.tgz#9dbd8084e2a976e551d5e5e70b978ed8b5965741" + integrity sha512-lbLLlXxdCZOSJMCInKH2+9V/77ET2J6NPQHpFI0kda61Dd1KglJs+fPQBchizmzYSOJBgdTajhPqBO1xxLywvg== + dependencies: + "@types/node" "*" + "@types/ssh2-streams" "*" + "@types/stack-utils@^2.0.3": version "2.0.3" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.3.tgz#6209321eb2c1712a7e7466422b8cb1fc0d9dd5d8" @@ -5061,6 +5152,32 @@ append-transform@^2.0.0: dependencies: default-require-extensions "^3.0.0" +archiver-utils@^5.0.0, archiver-utils@^5.0.2: + version "5.0.2" + resolved "https://registry.yarnpkg.com/archiver-utils/-/archiver-utils-5.0.2.tgz#63bc719d951803efc72cf961a56ef810760dd14d" + integrity sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA== + dependencies: + glob "^10.0.0" + graceful-fs "^4.2.0" + is-stream "^2.0.1" + lazystream "^1.0.0" + lodash "^4.17.15" + normalize-path "^3.0.0" + readable-stream "^4.0.0" + +archiver@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/archiver/-/archiver-7.0.1.tgz#c9d91c350362040b8927379c7aa69c0655122f61" + integrity sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ== + dependencies: + archiver-utils "^5.0.2" + async "^3.2.4" + buffer-crc32 "^1.0.0" + readable-stream "^4.0.0" + readdir-glob "^1.1.2" + tar-stream "^3.0.0" + zip-stream "^6.0.1" + archy@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/archy/-/archy-1.0.0.tgz#f9c8c13757cc1dd7bc379ac77b2c62a5c2868c40" @@ -5093,7 +5210,7 @@ array-timsort@^1.0.3: resolved "https://registry.yarnpkg.com/array-timsort/-/array-timsort-1.0.3.tgz#3c9e4199e54fb2b9c3fe5976396a21614ef0d926" integrity sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ== -asn1@~0.2.3: +asn1@^0.2.6, asn1@~0.2.3: version "0.2.6" resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.6.tgz#0d3a7bb6e64e02a90c0303b31f292868ea09a08d" integrity sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ== @@ -5119,7 +5236,12 @@ astring@1.9.0, astring@^1.8.0: resolved "https://registry.yarnpkg.com/astring/-/astring-1.9.0.tgz#cc73e6062a7eb03e7d19c22d8b0b3451fd9bfeef" integrity sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg== -async@3.2.6, async@^3.2.6: +async-lock@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/async-lock/-/async-lock-1.4.1.tgz#56b8718915a9b68b10fce2f2a9a3dddf765ef53f" + integrity sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ== + +async@3.2.6, async@^3.2.4, async@^3.2.6: version "3.2.6" resolved "https://registry.yarnpkg.com/async/-/async-3.2.6.tgz#1b0728e14929d51b85b449b7f06e27c1145e38ce" integrity sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA== @@ -5318,11 +5440,49 @@ balanced-match@^4.0.2: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-4.0.4.tgz#bfb10662feed8196a2c62e7c68e17720c274179a" integrity sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA== -bare-events@^2.7.0: +bare-events@^2.5.4, bare-events@^2.7.0: version "2.8.2" resolved "https://registry.yarnpkg.com/bare-events/-/bare-events-2.8.2.tgz#7b3e10bd8e1fc80daf38bb516921678f566ab89f" integrity sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ== +bare-fs@^4.0.1, bare-fs@^4.5.5: + version "4.6.0" + resolved "https://registry.yarnpkg.com/bare-fs/-/bare-fs-4.6.0.tgz#ff2f10c8238d3ff94f2704f0c581b197b47ef112" + integrity sha512-2YkS7NuiJceSEbyEOdSNLE9tsGd+f4+f7C+Nik/MCk27SYdwIMPT/yRKvg++FZhQXgk0KWJKJyXX9RhVV0RGqA== + dependencies: + bare-events "^2.5.4" + bare-path "^3.0.0" + bare-stream "^2.6.4" + bare-url "^2.2.2" + fast-fifo "^1.3.2" + +bare-os@^3.0.1: + version "3.8.7" + resolved "https://registry.yarnpkg.com/bare-os/-/bare-os-3.8.7.tgz#09c7c4e8c817de750b0b69b65c929513f69ede65" + integrity sha512-G4Gr1UsGeEy2qtDTZwL7JFLo2wapUarz7iTMcYcMFdS89AIQuBoyjgXZz0Utv7uHs3xA9LckhVbeBi8lEQrC+w== + +bare-path@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/bare-path/-/bare-path-3.0.0.tgz#b59d18130ba52a6af9276db3e96a2e3d3ea52178" + integrity sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw== + dependencies: + bare-os "^3.0.1" + +bare-stream@^2.6.4: + version "2.12.0" + resolved "https://registry.yarnpkg.com/bare-stream/-/bare-stream-2.12.0.tgz#f1b6818768113790bbbfde90f47003f370c8778e" + integrity sha512-w28i8lkBgREV3rPXGbgK+BO66q+ZpKqRWrZLiCdmmUlLPrQ45CzkvRhN+7lnv00Gpi2zy5naRxnUFAxCECDm9g== + dependencies: + streamx "^2.25.0" + teex "^1.0.1" + +bare-url@^2.2.2: + version "2.4.0" + resolved "https://registry.yarnpkg.com/bare-url/-/bare-url-2.4.0.tgz#1546d63057917189cab9b24629e946e1e8f7af31" + integrity sha512-NSTU5WN+fy/L0DDenfE8SXQna4voXuW0FHM7wH8i3/q9khUSchfPbPezO4zSFMnDGIf9YE+mt/RWhZgNRKRIXA== + dependencies: + bare-path "^3.0.0" + base64-js@^1.3.1: version "1.5.1" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" @@ -5345,7 +5505,7 @@ batch@0.6.1: resolved "https://registry.yarnpkg.com/batch/-/batch-0.6.1.tgz#dc34314f4e679318093fc760272525f94bf25c16" integrity sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw== -bcrypt-pbkdf@^1.0.0: +bcrypt-pbkdf@^1.0.0, bcrypt-pbkdf@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e" integrity sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w== @@ -5485,6 +5645,11 @@ bser@2.1.1: dependencies: node-int64 "^0.4.0" +buffer-crc32@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-1.0.0.tgz#a10993b9055081d55304bd9feb4a072de179f405" + integrity sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w== + buffer-equal-constant-time@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" @@ -5511,6 +5676,11 @@ buffer@^6.0.3: base64-js "^1.3.1" ieee754 "^1.2.1" +buildcheck@~0.0.6: + version "0.0.7" + resolved "https://registry.yarnpkg.com/buildcheck/-/buildcheck-0.0.7.tgz#07a5e76c10ead8fa67d9e4c587b68f49e8f29d61" + integrity sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA== + bundle-name@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/bundle-name/-/bundle-name-4.1.0.tgz#f3b96b34160d6431a19d7688135af7cfb8797889" @@ -5518,6 +5688,11 @@ bundle-name@^4.1.0: dependencies: run-applescript "^7.0.0" +byline@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/byline/-/byline-5.0.0.tgz#741c5216468eadc457b03410118ad77de8c1ddb1" + integrity sha512-s6webAy+R4SR8XVuJWt2V2rGvhnrhxN+9S15GNuTK3wKPOXFF6RNc+8ug2XhH+2s4f+uudG4kUVYmYOQWL2g0Q== + bytes@3.1.2, bytes@^3.1.2, bytes@~3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" @@ -5928,6 +6103,17 @@ commondir@^1.0.1: resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" integrity sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg== +compress-commons@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/compress-commons/-/compress-commons-6.0.2.tgz#26d31251a66b9d6ba23a84064ecd3a6a71d2609e" + integrity sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg== + dependencies: + crc-32 "^1.2.0" + crc32-stream "^6.0.0" + is-stream "^2.0.1" + normalize-path "^3.0.0" + readable-stream "^4.0.0" + compressible@~2.0.18: version "2.0.18" resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.18.tgz#af53cca6b070d4c3c0750fbd77286a6d7cc46fba" @@ -6116,6 +6302,27 @@ cosmiconfig@^9.0.0: js-yaml "^4.1.0" parse-json "^5.2.0" +cpu-features@~0.0.10: + version "0.0.10" + resolved "https://registry.yarnpkg.com/cpu-features/-/cpu-features-0.0.10.tgz#9aae536db2710c7254d7ed67cb3cbc7d29ad79c5" + integrity sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA== + dependencies: + buildcheck "~0.0.6" + nan "^2.19.0" + +crc-32@^1.2.0: + version "1.2.2" + resolved "https://registry.yarnpkg.com/crc-32/-/crc-32-1.2.2.tgz#3cad35a934b8bf71f25ca524b6da51fb7eace2ff" + integrity sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ== + +crc32-stream@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/crc32-stream/-/crc32-stream-6.0.0.tgz#8529a3868f8b27abb915f6c3617c0fadedbf9430" + integrity sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g== + dependencies: + crc-32 "^1.2.0" + readable-stream "^4.0.0" + create-require@^1.1.0: version "1.1.1" resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" @@ -6806,6 +7013,36 @@ dns-packet@^5.2.2: dependencies: "@leichtgewicht/ip-codec" "^2.0.1" +docker-compose@^1.4.2: + version "1.4.2" + resolved "https://registry.yarnpkg.com/docker-compose/-/docker-compose-1.4.2.tgz#a389b9ab754c722bccf97fba6206859098edf835" + integrity sha512-rPHigTKGaEHpkUmfd69QgaOp+Os5vGJwG/Ry8lcr8W/382AmI+z/D7qoa9BybKIkqNppaIbs8RYeHSevdQjWww== + dependencies: + yaml "^2.2.2" + +docker-modem@^5.0.7: + version "5.0.7" + resolved "https://registry.yarnpkg.com/docker-modem/-/docker-modem-5.0.7.tgz#57f3f0e2c7a893e66a0d4a626f9cbc933d77157b" + integrity sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA== + dependencies: + debug "^4.1.1" + readable-stream "^3.5.0" + split-ca "^1.0.1" + ssh2 "^1.15.0" + +dockerode@^4.0.10: + version "4.0.10" + resolved "https://registry.yarnpkg.com/dockerode/-/dockerode-4.0.10.tgz#d0fa159d87eace6f59aa1399517c3d07a7455f73" + integrity sha512-8L/P9JynLBiG7/coiA4FlQXegHltRqS0a+KqI44P1zgQh8QLHTg7FKOwhkBgSJwZTeHsq30WRoVFLuwkfK0YFg== + dependencies: + "@balena/dockerignore" "^1.0.2" + "@grpc/grpc-js" "^1.11.1" + "@grpc/proto-loader" "^0.7.13" + docker-modem "^5.0.7" + protobufjs "^7.3.2" + tar-fs "^2.1.4" + uuid "^10.0.0" + dom-accessibility-api@^0.5.9: version "0.5.16" resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz#5a7429e6066eb3664d911e33fb0e45de8eb08453" @@ -7853,6 +8090,11 @@ get-package-type@^0.1.0: resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a" integrity sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q== +get-port@^7.2.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/get-port/-/get-port-7.2.0.tgz#db0d52eb2d89890cdc010ed0e9a6f2d4b78cbbe7" + integrity sha512-afP4W205ONCuMoPBqcR6PSXnzX35KTcJygfJfcp+QY+uwm3p20p1YczWXhlICIzGMCxYBQcySEcOgsJcrkyobg== + get-proto@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" @@ -7921,7 +8163,7 @@ glob-to-regexp@^0.4.1: resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e" integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw== -glob@^10.3.10, glob@^10.4.1: +glob@^10.0.0, glob@^10.3.10, glob@^10.4.1: version "10.5.0" resolved "https://registry.yarnpkg.com/glob/-/glob-10.5.0.tgz#8ec0355919cd3338c28428a23d4f24ecc5fe738c" integrity sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg== @@ -8721,7 +8963,7 @@ is-promise@^4.0.0: resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-4.0.0.tgz#42ff9f84206c1991d26debf520dd5c01042dd2f3" integrity sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ== -is-stream@^2.0.0: +is-stream@^2.0.0, is-stream@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077" integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg== @@ -9530,6 +9772,13 @@ layout-base@^2.0.0: resolved "https://registry.yarnpkg.com/layout-base/-/layout-base-2.0.1.tgz#d0337913586c90f9c2c075292069f5c2da5dd285" integrity sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg== +lazystream@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/lazystream/-/lazystream-1.0.1.tgz#494c831062f1f9408251ec44db1cba29242a2638" + integrity sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw== + dependencies: + readable-stream "^2.0.5" + leaflet@^1.9.0: version "1.9.4" resolved "https://registry.yarnpkg.com/leaflet/-/leaflet-1.9.4.tgz#23fae724e282fa25745aff82ca4d394748db7d8d" @@ -9669,6 +9918,11 @@ lodash-es@4.17.23, lodash-es@^4.17.21, lodash-es@^4.17.23: resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.23.tgz#58c4360fd1b5d33afc6c0bbd3d1149349b1138e0" integrity sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg== +lodash.camelcase@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6" + integrity sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA== + lodash.debounce@^4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" @@ -9749,6 +10003,11 @@ lodash@4.17.21: resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== +lodash@^4.17.15: + version "4.18.1" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.18.1.tgz#ff2b66c1f6326d59513de2407bf881439812771c" + integrity sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q== + log-symbols@^4.0.0, log-symbols@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.1.0.tgz#3fbdbb95b4683ac9fc785111e792e558d4abd503" @@ -10682,6 +10941,13 @@ minimatch@^5.0.1: dependencies: brace-expansion "^2.0.1" +minimatch@^5.1.0: + version "5.1.9" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.9.tgz#1293ef15db0098b394540e8f9f744f9fda8dee4b" + integrity sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw== + dependencies: + brace-expansion "^2.0.1" + minimatch@^9.0.4, minimatch@^9.0.5: version "9.0.5" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" @@ -10721,6 +10987,11 @@ mkdirp@1.0.4: resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== +mkdirp@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-3.0.1.tgz#e44e4c5607fb279c168241713cc6e0fea9adcb50" + integrity sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg== + mlly@^1.7.4, mlly@^1.8.0: version "1.8.0" resolved "https://registry.yarnpkg.com/mlly/-/mlly-1.8.0.tgz#e074612b938af8eba1eaf43299cbc89cb72d824e" @@ -10754,6 +11025,11 @@ multicast-dns@^7.2.5: dns-packet "^5.2.2" thunky "^1.0.2" +nan@^2.19.0, nan@^2.23.0: + version "2.26.2" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.26.2.tgz#2e5e25764224c737b9897790b57c3294d4dcee9c" + integrity sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw== + nano-spawn@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/nano-spawn/-/nano-spawn-2.0.0.tgz#f1250434c09ae18870d4f729fc54b406cf85a3e1" @@ -11899,12 +12175,29 @@ prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: object-assign "^4.1.1" react-is "^16.13.1" +proper-lockfile@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/proper-lockfile/-/proper-lockfile-4.1.2.tgz#c8b9de2af6b2f1601067f98e01ac66baa223141f" + integrity sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA== + dependencies: + graceful-fs "^4.2.4" + retry "^0.12.0" + signal-exit "^3.0.2" + +properties-reader@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/properties-reader/-/properties-reader-3.0.1.tgz#576af69708759bb75672bfc162b80cc8a3d1bdb2" + integrity sha512-WPn+h9RGEExOKdu4bsF4HksG/uzd3cFq3MFtq8PsFeExPse5Ha/VOjQNyHhjboBFwGXGev6muJYTSPAOkROq2g== + dependencies: + "@kwsites/file-exists" "^1.1.1" + mkdirp "^3.0.1" + property-information@^7.0.0: version "7.1.0" resolved "https://registry.yarnpkg.com/property-information/-/property-information-7.1.0.tgz#b622e8646e02b580205415586b40804d3e8bfd5d" integrity sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ== -protobufjs@^7.2.4: +protobufjs@^7.2.4, protobufjs@^7.2.5, protobufjs@^7.3.2, protobufjs@^7.5.3: version "7.5.4" resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-7.5.4.tgz#885d31fe9c4b37f25d1bb600da30b1c5b37d286a" integrity sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg== @@ -12175,7 +12468,7 @@ read-cache@^1.0.0: dependencies: pify "^2.3.0" -readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@~2.3.6: +readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.5, readable-stream@~2.3.6: version "2.3.8" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.8.tgz#91125e8042bba1b9887f49345f6277027ce8be9b" integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== @@ -12188,7 +12481,7 @@ readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@~2.3.6: string_decoder "~1.1.1" util-deprecate "~1.0.1" -readable-stream@^3.0.6, readable-stream@^3.1.1, readable-stream@^3.4.0: +readable-stream@^3.0.6, readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.5.0: version "3.6.2" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== @@ -12208,6 +12501,13 @@ readable-stream@^4.0.0: process "^0.11.10" string_decoder "^1.3.0" +readdir-glob@^1.1.2: + version "1.1.3" + resolved "https://registry.yarnpkg.com/readdir-glob/-/readdir-glob-1.1.3.tgz#c3d831f51f5e7bfa62fa2ffbe4b508c640f09584" + integrity sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA== + dependencies: + minimatch "^5.1.0" + readdirp@^4.0.1: version "4.1.2" resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-4.1.2.tgz#eb85801435fbf2a7ee58f19e0921b068fc69948d" @@ -12516,6 +12816,11 @@ restore-cursor@^5.0.0: onetime "^7.0.0" signal-exit "^4.1.0" +retry@^0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b" + integrity sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow== + retry@^0.13.1: version "0.13.1" resolved "https://registry.yarnpkg.com/retry/-/retry-0.13.1.tgz#185b1587acf67919d63b357349e03537b2484658" @@ -13255,6 +13560,11 @@ spdy@^4.0.2: select-hose "^2.0.0" spdy-transport "^3.0.0" +split-ca@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/split-ca/-/split-ca-1.0.1.tgz#6c83aff3692fa61256e0cd197e05e9de157691a6" + integrity sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ== + split2@^4.0.0: version "4.2.0" resolved "https://registry.yarnpkg.com/split2/-/split2-4.2.0.tgz#c9c5920904d148bab0b9f67145f245a86aadbfa4" @@ -13265,6 +13575,25 @@ sprintf-js@^1.1.2: resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.1.3.tgz#4914b903a2f8b685d17fdf78a70e917e872e444a" integrity sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA== +ssh-remote-port-forward@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/ssh-remote-port-forward/-/ssh-remote-port-forward-1.0.4.tgz#72b0c5df8ec27ca300c75805cc6b266dee07e298" + integrity sha512-x0LV1eVDwjf1gmG7TTnfqIzf+3VPRz7vrNIjX6oYLbeCrf/PeVY6hkT68Mg+q02qXxQhrLjB0jfgvhevoCRmLQ== + dependencies: + "@types/ssh2" "^0.5.48" + ssh2 "^1.4.0" + +ssh2@^1.15.0, ssh2@^1.4.0: + version "1.17.0" + resolved "https://registry.yarnpkg.com/ssh2/-/ssh2-1.17.0.tgz#dc686e8e3abdbd4ad95d46fa139615903c12258c" + integrity sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ== + dependencies: + asn1 "^0.2.6" + bcrypt-pbkdf "^1.0.2" + optionalDependencies: + cpu-features "~0.0.10" + nan "^2.23.0" + sshpk@^1.18.0: version "1.18.0" resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.18.0.tgz#1663e55cddf4d688b86a46b77f0d5fe363aba028" @@ -13319,6 +13648,15 @@ stream-shift@^1.0.0: resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.3.tgz#85b8fab4d71010fc3ba8772e8046cc49b8a3864b" integrity sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ== +streamx@^2.12.5, streamx@^2.25.0: + version "2.25.0" + resolved "https://registry.yarnpkg.com/streamx/-/streamx-2.25.0.tgz#cc967e99390fda8b918b1eeaf3bc437637c8c7af" + integrity sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg== + dependencies: + events-universal "^1.0.0" + fast-fifo "^1.3.2" + text-decoder "^1.1.0" + streamx@^2.15.0: version "2.23.0" resolved "https://registry.yarnpkg.com/streamx/-/streamx-2.23.0.tgz#7d0f3d00d4a6c5de5728aecd6422b4008d66fd0b" @@ -13556,7 +13894,7 @@ tapable@^2.2.1, tapable@^2.3.0: resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.3.0.tgz#7e3ea6d5ca31ba8e078b560f0d83ce9a14aa8be6" integrity sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg== -tar-fs@^2.0.0: +tar-fs@^2.0.0, tar-fs@^2.1.4: version "2.1.4" resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.4.tgz#800824dbf4ef06ded9afea4acafe71c67c76b930" integrity sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ== @@ -13566,6 +13904,17 @@ tar-fs@^2.0.0: pump "^3.0.0" tar-stream "^2.1.4" +tar-fs@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-3.1.2.tgz#114b012f54796f31e62f3e57792820a80b83ae6e" + integrity sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw== + dependencies: + pump "^3.0.0" + tar-stream "^3.1.5" + optionalDependencies: + bare-fs "^4.0.1" + bare-path "^3.0.0" + tar-stream@3.1.7: version "3.1.7" resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-3.1.7.tgz#24b3fb5eabada19fe7338ed6d26e5f7c482e792b" @@ -13586,6 +13935,16 @@ tar-stream@^2.1.4, tar-stream@~2.2.0: inherits "^2.0.3" readable-stream "^3.1.1" +tar-stream@^3.0.0, tar-stream@^3.1.5: + version "3.1.8" + resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-3.1.8.tgz#a26f5b26c34dfd4936a4f8a9e694a8f5102af13d" + integrity sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ== + dependencies: + b4a "^1.6.4" + bare-fs "^4.5.5" + fast-fifo "^1.2.0" + streamx "^2.15.0" + tar@^7.0.1: version "7.5.7" resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.7.tgz#adf99774008ba1c89819f15dbd6019c630539405" @@ -13605,6 +13964,13 @@ tcp-port-used@^1.0.2: debug "4.3.1" is2 "^2.0.6" +teex@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/teex/-/teex-1.0.1.tgz#b8fa7245ef8e8effa8078281946c85ab780a0b12" + integrity sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg== + dependencies: + streamx "^2.12.5" + terser-webpack-plugin@^5.3.16, terser-webpack-plugin@^5.3.3: version "5.3.16" resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz#741e448cc3f93d8026ebe4f7ef9e4afacfd56330" @@ -13635,6 +14001,27 @@ test-exclude@^6.0.0, test-exclude@^7.0.1: glob "^10.4.1" minimatch "^9.0.4" +testcontainers@^11.14.0: + version "11.14.0" + resolved "https://registry.yarnpkg.com/testcontainers/-/testcontainers-11.14.0.tgz#ee4262acede48788018adc38f902bda86bd21585" + integrity sha512-r9pniwv/iwzyHaI7gwAvAm4Y+IvjJg3vBWdjrUCaDMc2AXIr4jKbq7jJO18Mw2ybs73pZy1Aj7p/4RVBGMRWjg== + dependencies: + "@balena/dockerignore" "^1.0.2" + "@types/dockerode" "^4.0.1" + archiver "^7.0.1" + async-lock "^1.4.1" + byline "^5.0.0" + debug "^4.4.3" + docker-compose "^1.4.2" + dockerode "^4.0.10" + get-port "^7.2.0" + proper-lockfile "^4.1.2" + properties-reader "^3.0.1" + ssh-remote-port-forward "^1.0.4" + tar-fs "^3.1.2" + tmp "^0.2.5" + undici "^7.24.5" + text-decoder@^1.1.0: version "1.2.3" resolved "https://registry.yarnpkg.com/text-decoder/-/text-decoder-1.2.3.tgz#b19da364d981b2326d5f43099c310cc80d770c65" @@ -13702,7 +14089,7 @@ tldts@^6.1.32: dependencies: tldts-core "^6.1.86" -tmp@~0.2.1: +tmp@^0.2.5, tmp@~0.2.1: version "0.2.5" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.5.tgz#b06bcd23f0f3c8357b426891726d16015abfd8f8" integrity sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow== @@ -13977,11 +14364,21 @@ uncrypto@^0.1.3: resolved "https://registry.yarnpkg.com/uncrypto/-/uncrypto-0.1.3.tgz#e1288d609226f2d02d8d69ee861fa20d8348ef2b" integrity sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q== +undici-types@~5.26.4: + version "5.26.5" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" + integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== + undici-types@~7.16.0: version "7.16.0" resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.16.0.tgz#ffccdff36aea4884cbfce9a750a0580224f58a46" integrity sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw== +undici@^7.24.5: + version "7.24.7" + resolved "https://registry.yarnpkg.com/undici/-/undici-7.24.7.tgz#af9535341bbe80625ca403a02418477a5c6a8760" + integrity sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ== + unicode-canonical-property-names-ecmascript@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz#cb3173fe47ca743e228216e4a3ddc4c84d628cc2" @@ -14155,6 +14552,11 @@ utils-merge@1.0.1: resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== +uuid@^10.0.0: + version "10.0.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-10.0.0.tgz#5a95aa454e6e002725c79055fd42aaba30ca6294" + integrity sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ== + uuid@^11.1.0: version "11.1.0" resolved "https://registry.yarnpkg.com/uuid/-/uuid-11.1.0.tgz#9549028be1753bb934fc96e2bca09bb4105ae912" @@ -14737,16 +15139,16 @@ yaml@^1.10.0: resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b" integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== +yaml@^2.2.2, yaml@^2.8.3: + version "2.8.3" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.8.3.tgz#a0d6bd2efb3dd03c59370223701834e60409bd7d" + integrity sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg== + yaml@^2.6.0, yaml@^2.8.1: version "2.8.2" resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.8.2.tgz#5694f25eca0ce9c3e7a9d9e00ce0ddabbd9e35c5" integrity sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A== -yaml@^2.8.3: - version "2.8.3" - resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.8.3.tgz#a0d6bd2efb3dd03c59370223701834e60409bd7d" - integrity sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg== - yargs-parser@21.1.1, yargs-parser@^21.1.1: version "21.1.1" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" @@ -14805,6 +15207,15 @@ yocto-queue@^1.0.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.2.2.tgz#3e09c95d3f1aa89a58c114c99223edf639152c00" integrity sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ== +zip-stream@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/zip-stream/-/zip-stream-6.0.1.tgz#e141b930ed60ccaf5d7fa9c8260e0d1748a2bbfb" + integrity sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA== + dependencies: + archiver-utils "^5.0.0" + compress-commons "^6.0.2" + readable-stream "^4.0.0" + zod-from-json-schema@^0.5.2: version "0.5.2" resolved "https://registry.yarnpkg.com/zod-from-json-schema/-/zod-from-json-schema-0.5.2.tgz#0d56e1a3d3a0f15b6bf22d1aa9653bbf85600eb4"