diff --git a/apps/gateway/src/mcp/mcp.controller.ts b/apps/gateway/src/mcp/mcp.controller.ts index 55ad75e..cfdbb3e 100644 --- a/apps/gateway/src/mcp/mcp.controller.ts +++ b/apps/gateway/src/mcp/mcp.controller.ts @@ -3,7 +3,11 @@ import { Logger } from '@nestjs/common'; import { fromNodeHeaders } from 'better-auth/node'; import type { Auth } from '@mosaicstack/auth'; import type { NestFastifyApplication } from '@nestjs/platform-fastify'; -import type { McpService } from './mcp.service.js'; +import { + createMcpActorContext, + deriveMcpToolScopesForUser, + type McpService, +} from './mcp.service.js'; import { AUTH } from '../auth/auth.tokens.js'; /** @@ -67,14 +71,25 @@ async function handleMcpRequest( return; } - const userId = result.user.id; + const authUser = result.user as { + id: string; + role?: string | null; + tenantId?: string | null; + organizationId?: string | null; + }; + const actor = createMcpActorContext({ + userId: authUser.id, + role: authUser.role, + tenantId: authUser.tenantId ?? authUser.organizationId ?? undefined, + scopes: deriveMcpToolScopesForUser({ role: authUser.role }), + }); // ─── Session routing ───────────────────────────────────────────────────── const sessionId = req.raw.headers['mcp-session-id']; if (typeof sessionId === 'string' && sessionId.length > 0) { // Existing session request - const transport = mcpService.getSession(sessionId); + const transport = mcpService.getSession(sessionId, actor); if (!transport) { logger.warn(`MCP session not found: ${sessionId}`); reply.raw.writeHead(404, { 'Content-Type': 'application/json' }); @@ -112,8 +127,10 @@ async function handleMcpRequest( } // Create new session and handle this initializing request - const { transport } = mcpService.createSession(userId); - logger.log(`New MCP session created for user ${userId}`); + const { transport } = mcpService.createSession(actor); + logger.log( + `New MCP session created for actor=${actor.userId} tenant=${actor.tenantId} correlation=${actor.correlationId}`, + ); await transport.handleRequest(req.raw, reply.raw, body); } diff --git a/apps/gateway/src/mcp/mcp.service.spec.ts b/apps/gateway/src/mcp/mcp.service.spec.ts new file mode 100644 index 0000000..9842fee --- /dev/null +++ b/apps/gateway/src/mcp/mcp.service.spec.ts @@ -0,0 +1,461 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { z } from 'zod'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { Brain } from '@mosaicstack/brain'; +import type { Memory } from '@mosaicstack/memory'; +import type { EmbeddingService } from '../memory/embedding.service.js'; +import type { CoordService } from '../coord/coord.service.js'; +import { + assertMcpToolAuthorized, + createMcpActorContext, + deriveMcpToolScopesForUser, + MCP_TOOL_SCOPES, + McpService, + type McpToolName, +} from './mcp.service.js'; + +type ToolResult = { content: Array<{ type: 'text'; text: string }> }; +type ToolHandler = (params: Record) => Promise; + +interface CapturedTool { + inputSchema: z.ZodType; + handler: ToolHandler; +} + +function makeCapturingServer(): { server: McpServer; tools: Map } { + const tools = new Map(); + const server = { + registerTool(name: string, config: { inputSchema: z.ZodType }, handler: ToolHandler): void { + tools.set(name, { inputSchema: config.inputSchema, handler }); + }, + }; + return { server: server as unknown as McpServer, tools }; +} + +function makeService(opts?: { + projects?: Array & { id: string; ownerId?: string | null }>; + missions?: Array< + Record & { id: string; projectId?: string | null; userId?: string | null } + >; + tasks?: Array< + Record & { + id: string; + projectId?: string | null; + missionId?: string | null; + status?: string; + } + >; +}) { + const projects = opts?.projects ?? []; + const missions = opts?.missions ?? []; + const tasks = opts?.tasks ?? []; + const brain = { + projects: { + findAll: vi.fn(async () => projects), + findById: vi.fn(async (id: string) => projects.find((project) => project.id === id) ?? null), + }, + tasks: { + findAll: vi.fn(async () => tasks), + findById: vi.fn(async (id: string) => tasks.find((task) => task.id === id) ?? null), + findByProject: vi.fn(async (projectId: string) => + tasks.filter((task) => task.projectId === projectId), + ), + findByMission: vi.fn(async (missionId: string) => + tasks.filter((task) => task.missionId === missionId), + ), + findByStatus: vi.fn(async (status: string) => tasks.filter((task) => task.status === status)), + create: vi.fn(async (task: Record) => ({ id: 'task-1', ...task })), + update: vi.fn(async (id: string, updates: Record) => ({ id, ...updates })), + }, + missions: { + findAll: vi.fn(async () => missions), + findById: vi.fn(async (id: string) => missions.find((mission) => mission.id === id) ?? null), + findByProject: vi.fn(async (projectId: string) => + missions.filter((mission) => mission.projectId === projectId), + ), + }, + conversations: { + findAll: vi.fn(async (userId: string) => [{ id: 'conversation-1', userId }]), + }, + } as unknown as Brain; + + const memory = { + insights: { + searchByEmbedding: vi.fn(async (userId: string) => [{ id: 'insight-1', userId }]), + create: vi.fn(async (insight: Record) => ({ id: 'insight-2', ...insight })), + }, + preferences: { + findByUser: vi.fn(async (userId: string) => [{ key: 'theme', userId }]), + findByUserAndCategory: vi.fn(async (userId: string, category: string) => [ + { key: 'theme', userId, category }, + ]), + upsert: vi.fn(async (preference: Record) => ({ + id: 'pref-1', + ...preference, + })), + }, + } as unknown as Memory; + + const embeddings = { + available: true, + embed: vi.fn(async () => [0.1, 0.2, 0.3]), + } as unknown as EmbeddingService; + + const coord = { + getMissionStatus: vi.fn(async () => null), + listTasks: vi.fn(async () => []), + getTaskStatus: vi.fn(async () => null), + } as unknown as CoordService; + + return { + service: new McpService(brain, memory, embeddings, coord), + brain: brain as unknown as { + conversations: { findAll: ReturnType }; + tasks: { + create: ReturnType; + update: ReturnType; + }; + }, + memory: memory as unknown as { + insights: { searchByEmbedding: ReturnType }; + }, + coord: coord as unknown as { + listTasks: ReturnType; + }, + }; +} + +function getTool(tools: Map, name: McpToolName): CapturedTool { + const tool = tools.get(name); + if (!tool) throw new Error(`Missing captured tool ${name}`); + return tool; +} + +function makeMemberActor(userId = 'authenticated-user') { + return createMcpActorContext({ + userId, + role: 'member', + scopes: deriveMcpToolScopesForUser({ role: 'member' }), + }); +} + +function makeAdminActor(userId = 'admin-user', tenantId?: string) { + return createMcpActorContext({ + userId, + tenantId, + role: 'admin', + scopes: deriveMcpToolScopesForUser({ role: 'admin' }), + }); +} + +function makePlatformAdminActor(userId = 'platform-admin-user') { + return createMcpActorContext({ + userId, + role: 'platform-admin', + scopes: deriveMcpToolScopesForUser({ role: 'platform-admin' }), + }); +} + +describe('MCP actor identity and tool scope enforcement', () => { + it('derives immutable actor, tenant, channel, correlation, and explicit tool scopes server-side', () => { + const actor = createMcpActorContext({ + userId: ' user-authenticated ', + role: 'member', + scopes: deriveMcpToolScopesForUser({ role: 'member' }), + }); + + expect(actor.userId).toBe('user-authenticated'); + expect(actor.tenantId).toBe('user:user-authenticated'); + expect(actor.role).toBe('member'); + expect(actor.channel).toBe('mcp'); + expect(actor.correlationId).toMatch(/[0-9a-f-]{36}/i); + expect(actor.scopes.has(MCP_TOOL_SCOPES.memory_search)).toBe(true); + expect(actor.scopes.has(MCP_TOOL_SCOPES.coord_list_tasks)).toBe(false); + expect( + deriveMcpToolScopesForUser({ role: 'admin' }).has(MCP_TOOL_SCOPES.coord_list_tasks), + ).toBe(false); + expect( + deriveMcpToolScopesForUser({ role: 'platform-admin' }).has(MCP_TOOL_SCOPES.coord_list_tasks), + ).toBe(true); + }); + + it('fails closed when scopes are not supplied by the authenticated context policy', () => { + const actor = createMcpActorContext({ userId: 'user-authenticated' }); + + expect(actor.scopes.size).toBe(0); + expect(() => assertMcpToolAuthorized(actor, 'memory_search', { query: 'notes' })).toThrow( + 'MCP tool scope denied: memory:insight:read', + ); + }); + + it('fails closed when a tool caller supplies actor or tenant identity fields', () => { + const actor = createMcpActorContext({ userId: 'user-authenticated' }); + + expect(() => + assertMcpToolAuthorized(actor, 'memory_search', { + userId: 'victim-user', + query: 'private data', + }), + ).toThrow('MCP caller-controlled identity field is forbidden: userId'); + + expect(() => + assertMcpToolAuthorized(actor, 'coord_list_tasks', { + tenantId: 'victim-tenant', + projectPath: '/tmp/project', + }), + ).toThrow('MCP caller-controlled identity field is forbidden: tenantId'); + + expect(() => + assertMcpToolAuthorized(actor, 'brain_create_task', { + title: 'forged org', + organizationId: 'victim-org', + }), + ).toThrow('MCP caller-controlled identity field is forbidden: organizationId'); + + expect(() => + assertMcpToolAuthorized(actor, 'brain_update_task', { + title: 'forged team', + teamId: 'victim-team', + }), + ).toThrow('MCP caller-controlled identity field is forbidden: teamId'); + }); + + it('fails closed when the server-derived actor lacks the required per-tool scope', () => { + const actor = createMcpActorContext({ userId: 'user-authenticated', scopes: [] }); + + expect(() => assertMcpToolAuthorized(actor, 'memory_search', { query: 'notes' })).toThrow( + 'MCP tool scope denied: memory:insight:read', + ); + }); + + it('removes caller-controlled userId from memory schemas and never queries victim memory', async () => { + const { service, memory } = makeService(); + const { server, tools } = makeCapturingServer(); + const actor = makeMemberActor('authenticated-user'); + + service.registerTools(server, actor); + const tool = getTool(tools, 'memory_search'); + + expect(tool.inputSchema.safeParse({ userId: 'victim-user', query: 'anything' }).success).toBe( + false, + ); + await expect(tool.handler({ userId: 'victim-user', query: 'anything' })).rejects.toThrow( + 'MCP caller-controlled identity field is forbidden: userId', + ); + expect(memory.insights.searchByEmbedding).not.toHaveBeenCalled(); + + await tool.handler({ query: 'only my notes' }); + expect(memory.insights.searchByEmbedding).toHaveBeenCalledWith( + 'authenticated-user', + [0.1, 0.2, 0.3], + 5, + ); + }); + + it('binds conversation listing to the authenticated actor instead of a caller-supplied userId', async () => { + const { service, brain } = makeService(); + const { server, tools } = makeCapturingServer(); + const actor = makeMemberActor('authenticated-user'); + + service.registerTools(server, actor); + const tool = getTool(tools, 'brain_list_conversations'); + + expect(tool.inputSchema.safeParse({ userId: 'victim-user' }).success).toBe(false); + await expect(tool.handler({ userId: 'victim-user' })).rejects.toThrow( + 'MCP caller-controlled identity field is forbidden: userId', + ); + expect(brain.conversations.findAll).not.toHaveBeenCalled(); + + await tool.handler({}); + expect(brain.conversations.findAll).toHaveBeenCalledWith('authenticated-user'); + }); + + it('scopes brain project, mission, and task reads to the authenticated actor', async () => { + const { service } = makeService({ + projects: [ + { id: 'project-owned', ownerId: 'authenticated-user', name: 'owned' }, + { id: 'project-victim', ownerId: 'victim-user', name: 'victim' }, + ], + missions: [ + { id: 'mission-owned', projectId: 'project-owned' }, + { id: 'mission-victim', userId: 'victim-user', projectId: 'project-victim' }, + ], + tasks: [ + { id: 'task-owned-project', projectId: 'project-owned', status: 'not-started' }, + { id: 'task-owned-mission', missionId: 'mission-owned', status: 'not-started' }, + { id: 'task-victim-project', projectId: 'project-victim', status: 'not-started' }, + { id: 'task-unowned', status: 'not-started' }, + ], + }); + const { server, tools } = makeCapturingServer(); + const actor = makeMemberActor('authenticated-user'); + + service.registerTools(server, actor); + + const projects = JSON.parse( + (await getTool(tools, 'brain_list_projects').handler({})).content[0]!.text, + ); + expect(projects.map((project: { id: string }) => project.id)).toEqual(['project-owned']); + + const missions = JSON.parse( + (await getTool(tools, 'brain_list_missions').handler({})).content[0]!.text, + ); + expect(missions.map((mission: { id: string }) => mission.id)).toEqual(['mission-owned']); + + const tasks = JSON.parse( + (await getTool(tools, 'brain_list_tasks').handler({})).content[0]!.text, + ); + expect(tasks.map((task: { id: string }) => task.id)).toEqual([ + 'task-owned-project', + 'task-owned-mission', + ]); + }); + + it('enforces tenant boundaries for tenant-admin brain project, mission, and task reads', async () => { + const { service } = makeService({ + projects: [ + { + id: 'project-tenant-a', + ownerId: 'other-user-a', + teamId: 'tenant-a', + name: 'same tenant', + }, + { + id: 'project-tenant-b', + ownerId: 'other-user-b', + teamId: 'tenant-b', + name: 'other tenant', + }, + ], + missions: [ + { id: 'mission-tenant-a', tenantId: 'tenant-a', projectId: 'project-tenant-a' }, + { id: 'mission-tenant-b', tenantId: 'tenant-b', projectId: 'project-tenant-b' }, + ], + tasks: [ + { id: 'task-tenant-a', projectId: 'project-tenant-a', status: 'not-started' }, + { id: 'task-tenant-b', projectId: 'project-tenant-b', status: 'not-started' }, + ], + }); + const { server, tools } = makeCapturingServer(); + const actor = makeAdminActor('tenant-admin-user', 'tenant-a'); + + service.registerTools(server, actor); + + const projects = JSON.parse( + (await getTool(tools, 'brain_list_projects').handler({})).content[0]!.text, + ); + expect(projects.map((project: { id: string }) => project.id)).toEqual(['project-tenant-a']); + + const missions = JSON.parse( + (await getTool(tools, 'brain_list_missions').handler({})).content[0]!.text, + ); + expect(missions.map((mission: { id: string }) => mission.id)).toEqual(['mission-tenant-a']); + + const tasks = JSON.parse( + (await getTool(tools, 'brain_list_tasks').handler({})).content[0]!.text, + ); + expect(tasks.map((task: { id: string }) => task.id)).toEqual(['task-tenant-a']); + }); + + it('denies tenant-admin task writes outside the authenticated tenant', async () => { + const { service, brain } = makeService({ + projects: [ + { id: 'project-tenant-a', ownerId: 'other-user-a', teamId: 'tenant-a' }, + { id: 'project-tenant-b', ownerId: 'other-user-b', teamId: 'tenant-b' }, + ], + missions: [ + { id: 'mission-tenant-a', tenantId: 'tenant-a', projectId: 'project-tenant-a' }, + { id: 'mission-tenant-b', tenantId: 'tenant-b', projectId: 'project-tenant-b' }, + ], + tasks: [ + { id: 'task-tenant-a', projectId: 'project-tenant-a', status: 'not-started' }, + { id: 'task-tenant-b', projectId: 'project-tenant-b', status: 'not-started' }, + ], + }); + const { server, tools } = makeCapturingServer(); + const actor = makeAdminActor('tenant-admin-user', 'tenant-a'); + + service.registerTools(server, actor); + + await expect( + getTool(tools, 'brain_create_task').handler({ + title: 'unscoped tenant write', + }), + ).rejects.toThrow('MCP task scope denied'); + expect(brain.tasks.create).not.toHaveBeenCalled(); + + await expect( + getTool(tools, 'brain_create_task').handler({ + title: 'cross-tenant write', + projectId: 'project-tenant-b', + }), + ).rejects.toThrow('MCP task project scope denied'); + expect(brain.tasks.create).not.toHaveBeenCalled(); + + await expect( + getTool(tools, 'brain_update_task').handler({ + id: 'task-tenant-a', + projectId: 'project-tenant-b', + }), + ).rejects.toThrow('MCP task project scope denied'); + expect(brain.tasks.update).not.toHaveBeenCalled(); + + const updateResult = await getTool(tools, 'brain_update_task').handler({ + id: 'task-tenant-b', + title: 'cross-tenant update', + }); + expect(updateResult.content[0]!.text).toBe('Task not found: task-tenant-b'); + expect(brain.tasks.update).not.toHaveBeenCalled(); + + await getTool(tools, 'brain_create_task').handler({ + title: 'same-tenant write', + projectId: 'project-tenant-a', + }); + expect(brain.tasks.create).toHaveBeenCalledWith( + expect.objectContaining({ projectId: 'project-tenant-a', title: 'same-tenant write' }), + ); + }); + + it('keeps admin-only coordination tools on server-derived paths', async () => { + const { service, coord } = makeService(); + const { server, tools } = makeCapturingServer(); + const member = makeMemberActor('authenticated-user'); + const tenantAdmin = makeAdminActor('admin-user'); + const platformAdmin = makePlatformAdminActor('platform-admin-user'); + + service.registerTools(server, member); + const memberTool = getTool(tools, 'coord_list_tasks'); + expect(memberTool.inputSchema.safeParse({ projectPath: '/tmp/victim' }).success).toBe(false); + await expect(memberTool.handler({})).rejects.toThrow('MCP tool scope denied: coord:read'); + + tools.clear(); + service.registerTools(server, tenantAdmin); + const tenantAdminTool = getTool(tools, 'coord_list_tasks'); + await expect(tenantAdminTool.handler({})).rejects.toThrow('MCP tool scope denied: coord:read'); + + tools.clear(); + service.registerTools(server, platformAdmin); + const platformAdminTool = getTool(tools, 'coord_list_tasks'); + await platformAdminTool.handler({ projectPath: '/tmp/victim' }); + expect(coord.listTasks).toHaveBeenCalledWith(process.cwd()); + }); + + it('does not attach a guessed or stale-scope MCP session to another authenticated context', async () => { + const { service } = makeService(); + const owner = makeMemberActor('owner-user'); + const attacker = makeMemberActor('attacker-user'); + const admin = makeAdminActor('admin-user'); + const downgradedAdmin = makeMemberActor('admin-user'); + + const { sessionId, transport } = service.createSession(owner); + const staleSession = service.createSession(admin); + + expect(service.getSession(sessionId, owner)).toBe(transport); + expect(service.getSession(sessionId, attacker)).toBeNull(); + expect(service.getSession(sessionId, owner)).toBe(transport); + expect(service.getSession(staleSession.sessionId, downgradedAdmin)).toBeNull(); + expect(service.getSession(staleSession.sessionId, admin)).toBe(staleSession.transport); + + await service.onModuleDestroy(); + }); +}); diff --git a/apps/gateway/src/mcp/mcp.service.ts b/apps/gateway/src/mcp/mcp.service.ts index c32dfc0..e5fb6b9 100644 --- a/apps/gateway/src/mcp/mcp.service.ts +++ b/apps/gateway/src/mcp/mcp.service.ts @@ -10,11 +10,216 @@ import { MEMORY } from '../memory/memory.tokens.js'; import { EmbeddingService } from '../memory/embedding.service.js'; import { CoordService } from '../coord/coord.service.js'; +export const MCP_CALLER_IDENTITY_FIELDS = [ + 'actorId', + 'actor', + 'authenticatedUserId', + 'channel', + 'organizationId', + 'ownerId', + 'sessionUserId', + 'teamId', + 'tenant', + 'tenantId', + 'user', + 'userId', +] as const; + +type McpCallerIdentityField = (typeof MCP_CALLER_IDENTITY_FIELDS)[number]; + +export const MCP_TOOL_SCOPES = { + brain_list_projects: 'brain:project:read', + brain_get_project: 'brain:project:read', + brain_list_tasks: 'brain:task:read', + brain_create_task: 'brain:task:write', + brain_update_task: 'brain:task:write', + brain_list_missions: 'brain:mission:read', + brain_list_conversations: 'brain:conversation:read', + memory_search: 'memory:insight:read', + memory_get_preferences: 'memory:preference:read', + memory_save_preference: 'memory:preference:write', + memory_save_insight: 'memory:insight:write', + coord_mission_status: 'coord:read', + coord_list_tasks: 'coord:read', + coord_task_detail: 'coord:read', +} as const; + +export type McpToolName = keyof typeof MCP_TOOL_SCOPES; +export type McpToolScope = (typeof MCP_TOOL_SCOPES)[McpToolName]; + +export interface McpActorContext { + userId: string; + tenantId: string; + role: string; + channel: 'mcp'; + correlationId: string; + scopes: ReadonlySet; +} + interface SessionEntry { server: McpServer; transport: StreamableHTTPServerTransport; createdAt: Date; + actor: McpActorContext; +} + +const GLOBAL_ADMIN_MCP_SCOPES = new Set(Object.values(MCP_TOOL_SCOPES)); +const TENANT_ADMIN_MCP_SCOPES = new Set([ + MCP_TOOL_SCOPES.brain_list_projects, + MCP_TOOL_SCOPES.brain_get_project, + MCP_TOOL_SCOPES.brain_list_tasks, + MCP_TOOL_SCOPES.brain_create_task, + MCP_TOOL_SCOPES.brain_update_task, + MCP_TOOL_SCOPES.brain_list_missions, + MCP_TOOL_SCOPES.brain_list_conversations, + MCP_TOOL_SCOPES.memory_search, + MCP_TOOL_SCOPES.memory_get_preferences, + MCP_TOOL_SCOPES.memory_save_preference, + MCP_TOOL_SCOPES.memory_save_insight, +]); +const MEMBER_MCP_SCOPES = new Set([ + MCP_TOOL_SCOPES.brain_list_projects, + MCP_TOOL_SCOPES.brain_get_project, + MCP_TOOL_SCOPES.brain_list_tasks, + MCP_TOOL_SCOPES.brain_list_missions, + MCP_TOOL_SCOPES.brain_list_conversations, + MCP_TOOL_SCOPES.memory_search, + MCP_TOOL_SCOPES.memory_get_preferences, + MCP_TOOL_SCOPES.memory_save_preference, + MCP_TOOL_SCOPES.memory_save_insight, +]); + +export function deriveMcpToolScopesForUser(input: { + role?: string | null; +}): ReadonlySet { + if (input.role === 'platform-admin' || input.role === 'super-admin') { + return new Set(GLOBAL_ADMIN_MCP_SCOPES); + } + if (input.role === 'admin') { + return new Set(TENANT_ADMIN_MCP_SCOPES); + } + return new Set(MEMBER_MCP_SCOPES); +} + +export function createMcpActorContext(input: { userId: string; + tenantId?: string; + role?: string | null; + scopes?: Iterable; + correlationId?: string; +}): McpActorContext { + const userId = input.userId.trim(); + if (userId.length === 0) { + throw new Error('MCP authenticated user is required'); + } + + return { + userId, + tenantId: input.tenantId?.trim() || `user:${userId}`, + role: input.role ?? 'member', + channel: 'mcp', + correlationId: input.correlationId ?? randomUUID(), + scopes: new Set(input.scopes ?? []), + }; +} + +export function assertNoCallerControlledIdentity(params: unknown): void { + if (params === null || typeof params !== 'object') return; + + const keys = new Set(Object.keys(params)); + const forbidden = MCP_CALLER_IDENTITY_FIELDS.find((field: McpCallerIdentityField) => + keys.has(field), + ); + if (forbidden) { + throw new Error(`MCP caller-controlled identity field is forbidden: ${forbidden}`); + } +} + +export function assertMcpToolAuthorized( + actor: McpActorContext, + toolName: McpToolName, + params: unknown, +): void { + assertNoCallerControlledIdentity(params); + const requiredScope = MCP_TOOL_SCOPES[toolName]; + if (!actor.scopes.has(requiredScope)) { + throw new Error(`MCP tool scope denied: ${requiredScope}`); + } +} + +function strictObject(shape: T): z.ZodObject { + return z.object(shape).strict(); +} + +type TenantScopedLike = { + tenantId?: string | null; + organizationId?: string | null; + teamId?: string | null; +}; +type ProjectLike = TenantScopedLike & { id: string; ownerId?: string | null }; +type MissionLike = TenantScopedLike & { + id: string; + projectId?: string | null; + userId?: string | null; +}; +type TaskLike = TenantScopedLike & { + projectId?: string | null; + missionId?: string | null; + userId?: string | null; +}; + +function isGlobalAdminActor(actor: McpActorContext): boolean { + return actor.role === 'platform-admin' || actor.role === 'super-admin'; +} + +function isTenantAdminActor(actor: McpActorContext): boolean { + return actor.role === 'admin'; +} + +function matchesTenant(actor: McpActorContext, record: TenantScopedLike): boolean { + return ( + record.tenantId === actor.tenantId || + record.organizationId === actor.tenantId || + record.teamId === actor.tenantId + ); +} + +function filterProjectsForActor(actor: McpActorContext, projects: T[]): T[] { + if (isGlobalAdminActor(actor)) return projects; + return projects.filter( + (project) => + project.ownerId === actor.userId || + (isTenantAdminActor(actor) && matchesTenant(actor, project)), + ); +} + +function filterMissionsByDirectActorScope( + actor: McpActorContext, + missions: T[], +): T[] { + if (isGlobalAdminActor(actor)) return missions; + return missions.filter( + (mission) => + mission.userId === actor.userId || + (isTenantAdminActor(actor) && matchesTenant(actor, mission)), + ); +} + +function scopesEqual(left: ReadonlySet, right: ReadonlySet): boolean { + if (left.size !== right.size) return false; + for (const scope of left) { + if (!right.has(scope)) return false; + } + return true; +} + +function sameActorAuthorization(stored: McpActorContext, current: McpActorContext): boolean { + return ( + stored.userId === current.userId && + stored.tenantId === current.tenantId && + stored.role === current.role && + scopesEqual(stored.scopes, current.scopes) + ); } @Injectable() @@ -33,13 +238,18 @@ export class McpService implements OnModuleDestroy { * Creates a new MCP session with its own server + transport pair. * Returns the transport for use by the controller. */ - createSession(userId: string): { sessionId: string; transport: StreamableHTTPServerTransport } { + createSession(actor: McpActorContext): { + sessionId: string; + transport: StreamableHTTPServerTransport; + } { const sessionId = randomUUID(); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => sessionId, onsessioninitialized: (id) => { - this.logger.log(`MCP session initialized: ${id} for user ${userId}`); + this.logger.log( + `MCP session initialized: ${id} for actor=${actor.userId} tenant=${actor.tenantId} correlation=${actor.correlationId}`, + ); }, }); @@ -48,7 +258,7 @@ export class McpService implements OnModuleDestroy { { capabilities: { tools: {} } }, ); - this.registerTools(server, userId); + this.registerTools(server, actor); transport.onclose = () => { this.logger.log(`MCP session closed: ${sessionId}`); @@ -61,31 +271,126 @@ export class McpService implements OnModuleDestroy { ); }); - this.sessions.set(sessionId, { server, transport, createdAt: new Date(), userId }); + this.sessions.set(sessionId, { server, transport, createdAt: new Date(), actor }); return { sessionId, transport }; } /** - * Returns the transport for an existing session, or null if not found. + * Returns the transport for an existing session only when it belongs to the + * currently authenticated MCP actor. Guessed or cross-tenant session IDs grant + * no authority. */ - getSession(sessionId: string): StreamableHTTPServerTransport | null { - return this.sessions.get(sessionId)?.transport ?? null; + getSession(sessionId: string, actor: McpActorContext): StreamableHTTPServerTransport | null { + const entry = this.sessions.get(sessionId); + if (!entry) return null; + if (!sameActorAuthorization(entry.actor, actor)) { + this.logger.warn( + `MCP session actor or scope mismatch: session=${sessionId} actor=${actor.userId} tenant=${actor.tenantId} role=${actor.role}`, + ); + return null; + } + return entry.transport; + } + + private async isProjectAuthorized(actor: McpActorContext, projectId: string): Promise { + if (isGlobalAdminActor(actor)) return true; + const project = (await this.brain.projects.findById(projectId)) as ProjectLike | undefined; + return project ? filterProjectsForActor(actor, [project]).length === 1 : false; + } + + private async filterMissionsForActor( + actor: McpActorContext, + missions: T[], + ): Promise { + if (isGlobalAdminActor(actor)) return missions; + + const projects = (await this.brain.projects.findAll()) as ProjectLike[]; + const projectIds = new Set( + filterProjectsForActor(actor, projects).map((project) => project.id), + ); + + return missions.filter( + (mission) => + filterMissionsByDirectActorScope(actor, [mission]).length === 1 || + (typeof mission.projectId === 'string' && projectIds.has(mission.projectId)), + ); + } + + private async isMissionAuthorized(actor: McpActorContext, missionId: string): Promise { + if (isGlobalAdminActor(actor)) return true; + const mission = (await this.brain.missions.findById(missionId)) as MissionLike | undefined; + if (!mission) return false; + return (await this.filterMissionsForActor(actor, [mission])).length === 1; + } + + private async assertTaskReferencesAuthorized( + actor: McpActorContext, + refs: { projectId?: string | null; missionId?: string | null }, + ): Promise { + if (refs.projectId && !(await this.isProjectAuthorized(actor, refs.projectId))) { + throw new Error('MCP task project scope denied'); + } + if (refs.missionId && !(await this.isMissionAuthorized(actor, refs.missionId))) { + throw new Error('MCP task mission scope denied'); + } + } + + private async assertTaskCreateScopeAuthorized( + actor: McpActorContext, + refs: { projectId?: string | null; missionId?: string | null }, + ): Promise { + if (!isGlobalAdminActor(actor) && !refs.projectId && !refs.missionId) { + throw new Error('MCP task scope denied'); + } + await this.assertTaskReferencesAuthorized(actor, refs); + } + + private async filterTasksForActor( + actor: McpActorContext, + tasks: T[], + ): Promise { + if (isGlobalAdminActor(actor)) return tasks; + + const [projects, missions] = await Promise.all([ + this.brain.projects.findAll(), + this.brain.missions.findAll(), + ]); + const projectIds = new Set( + filterProjectsForActor(actor, projects as ProjectLike[]).map((project) => project.id), + ); + const missionIds = new Set( + (await this.filterMissionsForActor(actor, missions as MissionLike[])).map( + (mission) => mission.id, + ), + ); + + return tasks.filter( + (task) => + task.userId === actor.userId || + (isTenantAdminActor(actor) && matchesTenant(actor, task)) || + (typeof task.projectId === 'string' && projectIds.has(task.projectId)) || + (typeof task.missionId === 'string' && missionIds.has(task.missionId)), + ); } /** * Registers all platform tools on the given McpServer instance. */ - private registerTools(server: McpServer, _userId: string): void { + registerTools(server: McpServer, actor: McpActorContext): void { // ─── Brain: Project tools ──────────────────────────────────────────── server.registerTool( 'brain_list_projects', { description: 'List all projects in the brain.', - inputSchema: z.object({}), + inputSchema: strictObject({}), }, - async () => { - const projects = await this.brain.projects.findAll(); + async (params) => { + assertMcpToolAuthorized(actor, 'brain_list_projects', params); + const projects = filterProjectsForActor( + actor, + (await this.brain.projects.findAll()) as ProjectLike[], + ); return { content: [{ type: 'text' as const, text: JSON.stringify(projects, null, 2) }], }; @@ -96,17 +401,21 @@ export class McpService implements OnModuleDestroy { 'brain_get_project', { description: 'Get a project by ID.', - inputSchema: z.object({ + inputSchema: strictObject({ id: z.string().describe('Project ID (UUID)'), }), }, - async ({ id }) => { - const project = await this.brain.projects.findById(id); + async ({ id, ...params }) => { + assertMcpToolAuthorized(actor, 'brain_get_project', params); + const project = (await this.brain.projects.findById(id)) as ProjectLike | undefined; + const authorizedProject = project ? filterProjectsForActor(actor, [project])[0] : undefined; return { content: [ { type: 'text' as const, - text: project ? JSON.stringify(project, null, 2) : `Project not found: ${id}`, + text: authorizedProject + ? JSON.stringify(authorizedProject, null, 2) + : `Project not found: ${id}`, }, ], }; @@ -119,20 +428,23 @@ export class McpService implements OnModuleDestroy { 'brain_list_tasks', { description: 'List tasks, optionally filtered by project, mission, or status.', - inputSchema: z.object({ + inputSchema: strictObject({ projectId: z.string().optional().describe('Filter by project ID'), missionId: z.string().optional().describe('Filter by mission ID'), status: z.string().optional().describe('Filter by status'), }), }, - async ({ projectId, missionId, status }) => { + async (params) => { + assertMcpToolAuthorized(actor, 'brain_list_tasks', params); + const { projectId, missionId, status } = params; type TaskStatus = 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled'; let tasks; if (projectId) tasks = await this.brain.tasks.findByProject(projectId); else if (missionId) tasks = await this.brain.tasks.findByMission(missionId); else if (status) tasks = await this.brain.tasks.findByStatus(status as TaskStatus); else tasks = await this.brain.tasks.findAll(); - return { content: [{ type: 'text' as const, text: JSON.stringify(tasks, null, 2) }] }; + const scopedTasks = await this.filterTasksForActor(actor, tasks as TaskLike[]); + return { content: [{ type: 'text' as const, text: JSON.stringify(scopedTasks, null, 2) }] }; }, ); @@ -140,7 +452,7 @@ export class McpService implements OnModuleDestroy { 'brain_create_task', { description: 'Create a new task in the brain.', - inputSchema: z.object({ + inputSchema: strictObject({ title: z.string().describe('Task title'), description: z.string().optional().describe('Task description'), projectId: z.string().optional().describe('Project ID'), @@ -149,6 +461,8 @@ export class McpService implements OnModuleDestroy { }), }, async (params) => { + assertMcpToolAuthorized(actor, 'brain_create_task', params); + await this.assertTaskCreateScopeAuthorized(actor, params); type Priority = 'low' | 'medium' | 'high' | 'critical'; const task = await this.brain.tasks.create({ ...params, @@ -162,7 +476,7 @@ export class McpService implements OnModuleDestroy { 'brain_update_task', { description: 'Update an existing task.', - inputSchema: z.object({ + inputSchema: strictObject({ id: z.string().describe('Task ID'), title: z.string().optional(), description: z.string().optional(), @@ -171,9 +485,17 @@ export class McpService implements OnModuleDestroy { .optional() .describe('not-started, in-progress, blocked, done, cancelled'), priority: z.string().optional(), + projectId: z.string().optional().describe('Project ID'), + missionId: z.string().optional().describe('Mission ID'), }), }, async ({ id, ...updates }) => { + assertMcpToolAuthorized(actor, 'brain_update_task', updates); + const existing = (await this.brain.tasks.findById(id)) as TaskLike | undefined; + if (!existing || (await this.filterTasksForActor(actor, [existing])).length === 0) { + return { content: [{ type: 'text' as const, text: `Task not found: ${id}` }] }; + } + await this.assertTaskReferencesAuthorized(actor, updates); type TaskStatus = 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled'; type Priority = 'low' | 'medium' | 'high' | 'critical'; const task = await this.brain.tasks.update(id, { @@ -198,14 +520,19 @@ export class McpService implements OnModuleDestroy { 'brain_list_missions', { description: 'List all missions, optionally filtered by project.', - inputSchema: z.object({ + inputSchema: strictObject({ projectId: z.string().optional().describe('Filter by project ID'), }), }, - async ({ projectId }) => { - const missions = projectId - ? await this.brain.missions.findByProject(projectId) - : await this.brain.missions.findAll(); + async (params) => { + assertMcpToolAuthorized(actor, 'brain_list_missions', params); + const { projectId } = params; + const missions = await this.filterMissionsForActor( + actor, + (projectId + ? await this.brain.missions.findByProject(projectId) + : await this.brain.missions.findAll()) as MissionLike[], + ); return { content: [{ type: 'text' as const, text: JSON.stringify(missions, null, 2) }] }; }, ); @@ -213,13 +540,12 @@ export class McpService implements OnModuleDestroy { server.registerTool( 'brain_list_conversations', { - description: 'List conversations for a user.', - inputSchema: z.object({ - userId: z.string().describe('User ID'), - }), + description: 'List conversations for the authenticated MCP actor.', + inputSchema: strictObject({}), }, - async ({ userId }) => { - const conversations = await this.brain.conversations.findAll(userId); + async (params) => { + assertMcpToolAuthorized(actor, 'brain_list_conversations', params); + const conversations = await this.brain.conversations.findAll(actor.userId); return { content: [{ type: 'text' as const, text: JSON.stringify(conversations, null, 2) }], }; @@ -232,14 +558,15 @@ export class McpService implements OnModuleDestroy { 'memory_search', { description: - 'Search across stored insights and knowledge using natural language. Returns semantically similar results.', - inputSchema: z.object({ - userId: z.string().describe('User ID to search memory for'), + 'Search stored insights and knowledge for the authenticated MCP actor using natural language.', + inputSchema: strictObject({ query: z.string().describe('Natural language search query'), limit: z.number().optional().describe('Max results (default 5)'), }), }, - async ({ userId, query, limit }) => { + async (params) => { + assertMcpToolAuthorized(actor, 'memory_search', params); + const { query, limit } = params; if (!this.embeddings.available) { return { content: [ @@ -251,7 +578,11 @@ export class McpService implements OnModuleDestroy { }; } const embedding = await this.embeddings.embed(query); - const results = await this.memory.insights.searchByEmbedding(userId, embedding, limit ?? 5); + const results = await this.memory.insights.searchByEmbedding( + actor.userId, + embedding, + limit ?? 5, + ); return { content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }] }; }, ); @@ -259,20 +590,21 @@ export class McpService implements OnModuleDestroy { server.registerTool( 'memory_get_preferences', { - description: 'Retrieve stored preferences for a user.', - inputSchema: z.object({ - userId: z.string().describe('User ID'), + description: 'Retrieve stored preferences for the authenticated MCP actor.', + inputSchema: strictObject({ category: z .string() .optional() .describe('Filter by category: communication, coding, workflow, appearance, general'), }), }, - async ({ userId, category }) => { + async (params) => { + assertMcpToolAuthorized(actor, 'memory_get_preferences', params); + const { category } = params; type Cat = 'communication' | 'coding' | 'workflow' | 'appearance' | 'general'; const prefs = category - ? await this.memory.preferences.findByUserAndCategory(userId, category as Cat) - : await this.memory.preferences.findByUser(userId); + ? await this.memory.preferences.findByUserAndCategory(actor.userId, category as Cat) + : await this.memory.preferences.findByUser(actor.userId); return { content: [{ type: 'text' as const, text: JSON.stringify(prefs, null, 2) }] }; }, ); @@ -281,9 +613,8 @@ export class McpService implements OnModuleDestroy { 'memory_save_preference', { description: - 'Store a learned user preference (e.g., "prefers tables over paragraphs", "timezone: America/Chicago").', - inputSchema: z.object({ - userId: z.string().describe('User ID'), + 'Store a learned preference for the authenticated MCP actor (e.g., "prefers tables over paragraphs").', + inputSchema: strictObject({ key: z.string().describe('Preference key'), value: z.string().describe('Preference value (JSON string)'), category: z @@ -292,7 +623,9 @@ export class McpService implements OnModuleDestroy { .describe('Category: communication, coding, workflow, appearance, general'), }), }, - async ({ userId, key, value, category }) => { + async (params) => { + assertMcpToolAuthorized(actor, 'memory_save_preference', params); + const { key, value, category } = params; type Cat = 'communication' | 'coding' | 'workflow' | 'appearance' | 'general'; let parsedValue: unknown; try { @@ -301,7 +634,7 @@ export class McpService implements OnModuleDestroy { parsedValue = value; } const pref = await this.memory.preferences.upsert({ - userId, + userId: actor.userId, key, value: parsedValue, category: (category as Cat) ?? 'general', @@ -315,9 +648,8 @@ export class McpService implements OnModuleDestroy { 'memory_save_insight', { description: - 'Store a learned insight, decision, or knowledge extracted from the current interaction.', - inputSchema: z.object({ - userId: z.string().describe('User ID'), + 'Store a learned insight, decision, or knowledge for the authenticated MCP actor.', + inputSchema: strictObject({ content: z.string().describe('The insight or knowledge to store'), category: z .string() @@ -325,11 +657,13 @@ export class McpService implements OnModuleDestroy { .describe('Category: decision, learning, preference, fact, pattern, general'), }), }, - async ({ userId, content, category }) => { + async (params) => { + assertMcpToolAuthorized(actor, 'memory_save_insight', params); + const { content, category } = params; type Cat = 'decision' | 'learning' | 'preference' | 'fact' | 'pattern' | 'general'; const embedding = this.embeddings.available ? await this.embeddings.embed(content) : null; const insight = await this.memory.insights.create({ - userId, + userId: actor.userId, content, embedding, source: 'agent', @@ -346,16 +680,11 @@ export class McpService implements OnModuleDestroy { { description: 'Get the current orchestration mission status including milestones, tasks, and active session.', - inputSchema: z.object({ - projectPath: z - .string() - .optional() - .describe('Project path. Defaults to gateway working directory.'), - }), + inputSchema: strictObject({}), }, - async ({ projectPath }) => { - const resolvedPath = projectPath ?? process.cwd(); - const status = await this.coordService.getMissionStatus(resolvedPath); + async (params) => { + assertMcpToolAuthorized(actor, 'coord_mission_status', params); + const status = await this.coordService.getMissionStatus(process.cwd()); return { content: [ { @@ -371,16 +700,11 @@ export class McpService implements OnModuleDestroy { 'coord_list_tasks', { description: 'List all tasks from the orchestration TASKS.md file.', - inputSchema: z.object({ - projectPath: z - .string() - .optional() - .describe('Project path. Defaults to gateway working directory.'), - }), + inputSchema: strictObject({}), }, - async ({ projectPath }) => { - const resolvedPath = projectPath ?? process.cwd(); - const tasks = await this.coordService.listTasks(resolvedPath); + async (params) => { + assertMcpToolAuthorized(actor, 'coord_list_tasks', params); + const tasks = await this.coordService.listTasks(process.cwd()); return { content: [{ type: 'text' as const, text: JSON.stringify(tasks, null, 2) }] }; }, ); @@ -389,17 +713,14 @@ export class McpService implements OnModuleDestroy { 'coord_task_detail', { description: 'Get detailed status for a specific orchestration task.', - inputSchema: z.object({ + inputSchema: strictObject({ taskId: z.string().describe('Task ID (e.g. P2-005)'), - projectPath: z - .string() - .optional() - .describe('Project path. Defaults to gateway working directory.'), }), }, - async ({ taskId, projectPath }) => { - const resolvedPath = projectPath ?? process.cwd(); - const detail = await this.coordService.getTaskStatus(resolvedPath, taskId); + async (params) => { + assertMcpToolAuthorized(actor, 'coord_task_detail', params); + const { taskId } = params; + const detail = await this.coordService.getTaskStatus(process.cwd(), taskId); return { content: [ {