fix(security): enforce Tess MCP server identity #717

Merged
jason.woltje merged 1 commits from fix/tess-mcp-identity into main 2026-07-12 22:49:01 +00:00
3 changed files with 882 additions and 83 deletions

View File

@@ -3,7 +3,11 @@ import { Logger } from '@nestjs/common';
import { fromNodeHeaders } from 'better-auth/node'; import { fromNodeHeaders } from 'better-auth/node';
import type { Auth } from '@mosaicstack/auth'; import type { Auth } from '@mosaicstack/auth';
import type { NestFastifyApplication } from '@nestjs/platform-fastify'; 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'; import { AUTH } from '../auth/auth.tokens.js';
/** /**
@@ -67,14 +71,25 @@ async function handleMcpRequest(
return; 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 ───────────────────────────────────────────────────── // ─── Session routing ─────────────────────────────────────────────────────
const sessionId = req.raw.headers['mcp-session-id']; const sessionId = req.raw.headers['mcp-session-id'];
if (typeof sessionId === 'string' && sessionId.length > 0) { if (typeof sessionId === 'string' && sessionId.length > 0) {
// Existing session request // Existing session request
const transport = mcpService.getSession(sessionId); const transport = mcpService.getSession(sessionId, actor);
if (!transport) { if (!transport) {
logger.warn(`MCP session not found: ${sessionId}`); logger.warn(`MCP session not found: ${sessionId}`);
reply.raw.writeHead(404, { 'Content-Type': 'application/json' }); reply.raw.writeHead(404, { 'Content-Type': 'application/json' });
@@ -112,8 +127,10 @@ async function handleMcpRequest(
} }
// Create new session and handle this initializing request // Create new session and handle this initializing request
const { transport } = mcpService.createSession(userId); const { transport } = mcpService.createSession(actor);
logger.log(`New MCP session created for user ${userId}`); logger.log(
`New MCP session created for actor=${actor.userId} tenant=${actor.tenantId} correlation=${actor.correlationId}`,
);
await transport.handleRequest(req.raw, reply.raw, body); await transport.handleRequest(req.raw, reply.raw, body);
} }

View File

@@ -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<string, unknown>) => Promise<ToolResult>;
interface CapturedTool {
inputSchema: z.ZodType;
handler: ToolHandler;
}
function makeCapturingServer(): { server: McpServer; tools: Map<string, CapturedTool> } {
const tools = new Map<string, CapturedTool>();
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<Record<string, unknown> & { id: string; ownerId?: string | null }>;
missions?: Array<
Record<string, unknown> & { id: string; projectId?: string | null; userId?: string | null }
>;
tasks?: Array<
Record<string, unknown> & {
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<string, unknown>) => ({ id: 'task-1', ...task })),
update: vi.fn(async (id: string, updates: Record<string, unknown>) => ({ 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<string, unknown>) => ({ 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<string, unknown>) => ({
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<typeof vi.fn> };
tasks: {
create: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
};
},
memory: memory as unknown as {
insights: { searchByEmbedding: ReturnType<typeof vi.fn> };
},
coord: coord as unknown as {
listTasks: ReturnType<typeof vi.fn>;
},
};
}
function getTool(tools: Map<string, CapturedTool>, 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();
});
});

View File

@@ -10,11 +10,216 @@ import { MEMORY } from '../memory/memory.tokens.js';
import { EmbeddingService } from '../memory/embedding.service.js'; import { EmbeddingService } from '../memory/embedding.service.js';
import { CoordService } from '../coord/coord.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<McpToolScope>;
}
interface SessionEntry { interface SessionEntry {
server: McpServer; server: McpServer;
transport: StreamableHTTPServerTransport; transport: StreamableHTTPServerTransport;
createdAt: Date; createdAt: Date;
actor: McpActorContext;
}
const GLOBAL_ADMIN_MCP_SCOPES = new Set<McpToolScope>(Object.values(MCP_TOOL_SCOPES));
const TENANT_ADMIN_MCP_SCOPES = new Set<McpToolScope>([
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<McpToolScope>([
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<McpToolScope> {
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; userId: string;
tenantId?: string;
role?: string | null;
scopes?: Iterable<McpToolScope>;
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<T extends z.ZodRawShape>(shape: T): z.ZodObject<T> {
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<T extends ProjectLike>(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<T extends MissionLike>(
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<McpToolScope>, right: ReadonlySet<McpToolScope>): 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() @Injectable()
@@ -33,13 +238,18 @@ export class McpService implements OnModuleDestroy {
* Creates a new MCP session with its own server + transport pair. * Creates a new MCP session with its own server + transport pair.
* Returns the transport for use by the controller. * 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 sessionId = randomUUID();
const transport = new StreamableHTTPServerTransport({ const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => sessionId, sessionIdGenerator: () => sessionId,
onsessioninitialized: (id) => { 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: {} } }, { capabilities: { tools: {} } },
); );
this.registerTools(server, userId); this.registerTools(server, actor);
transport.onclose = () => { transport.onclose = () => {
this.logger.log(`MCP session closed: ${sessionId}`); 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 }; 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 { getSession(sessionId: string, actor: McpActorContext): StreamableHTTPServerTransport | null {
return this.sessions.get(sessionId)?.transport ?? 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<boolean> {
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<T extends MissionLike>(
actor: McpActorContext,
missions: T[],
): Promise<T[]> {
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<boolean> {
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<void> {
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<void> {
if (!isGlobalAdminActor(actor) && !refs.projectId && !refs.missionId) {
throw new Error('MCP task scope denied');
}
await this.assertTaskReferencesAuthorized(actor, refs);
}
private async filterTasksForActor<T extends TaskLike>(
actor: McpActorContext,
tasks: T[],
): Promise<T[]> {
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. * 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 ──────────────────────────────────────────── // ─── Brain: Project tools ────────────────────────────────────────────
server.registerTool( server.registerTool(
'brain_list_projects', 'brain_list_projects',
{ {
description: 'List all projects in the brain.', description: 'List all projects in the brain.',
inputSchema: z.object({}), inputSchema: strictObject({}),
}, },
async () => { async (params) => {
const projects = await this.brain.projects.findAll(); assertMcpToolAuthorized(actor, 'brain_list_projects', params);
const projects = filterProjectsForActor(
actor,
(await this.brain.projects.findAll()) as ProjectLike[],
);
return { return {
content: [{ type: 'text' as const, text: JSON.stringify(projects, null, 2) }], content: [{ type: 'text' as const, text: JSON.stringify(projects, null, 2) }],
}; };
@@ -96,17 +401,21 @@ export class McpService implements OnModuleDestroy {
'brain_get_project', 'brain_get_project',
{ {
description: 'Get a project by ID.', description: 'Get a project by ID.',
inputSchema: z.object({ inputSchema: strictObject({
id: z.string().describe('Project ID (UUID)'), id: z.string().describe('Project ID (UUID)'),
}), }),
}, },
async ({ id }) => { async ({ id, ...params }) => {
const project = await this.brain.projects.findById(id); 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 { return {
content: [ content: [
{ {
type: 'text' as const, 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', 'brain_list_tasks',
{ {
description: 'List tasks, optionally filtered by project, mission, or status.', description: 'List tasks, optionally filtered by project, mission, or status.',
inputSchema: z.object({ inputSchema: strictObject({
projectId: z.string().optional().describe('Filter by project ID'), projectId: z.string().optional().describe('Filter by project ID'),
missionId: z.string().optional().describe('Filter by mission ID'), missionId: z.string().optional().describe('Filter by mission ID'),
status: z.string().optional().describe('Filter by status'), 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'; type TaskStatus = 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
let tasks; let tasks;
if (projectId) tasks = await this.brain.tasks.findByProject(projectId); if (projectId) tasks = await this.brain.tasks.findByProject(projectId);
else if (missionId) tasks = await this.brain.tasks.findByMission(missionId); else if (missionId) tasks = await this.brain.tasks.findByMission(missionId);
else if (status) tasks = await this.brain.tasks.findByStatus(status as TaskStatus); else if (status) tasks = await this.brain.tasks.findByStatus(status as TaskStatus);
else tasks = await this.brain.tasks.findAll(); 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', 'brain_create_task',
{ {
description: 'Create a new task in the brain.', description: 'Create a new task in the brain.',
inputSchema: z.object({ inputSchema: strictObject({
title: z.string().describe('Task title'), title: z.string().describe('Task title'),
description: z.string().optional().describe('Task description'), description: z.string().optional().describe('Task description'),
projectId: z.string().optional().describe('Project ID'), projectId: z.string().optional().describe('Project ID'),
@@ -149,6 +461,8 @@ export class McpService implements OnModuleDestroy {
}), }),
}, },
async (params) => { async (params) => {
assertMcpToolAuthorized(actor, 'brain_create_task', params);
await this.assertTaskCreateScopeAuthorized(actor, params);
type Priority = 'low' | 'medium' | 'high' | 'critical'; type Priority = 'low' | 'medium' | 'high' | 'critical';
const task = await this.brain.tasks.create({ const task = await this.brain.tasks.create({
...params, ...params,
@@ -162,7 +476,7 @@ export class McpService implements OnModuleDestroy {
'brain_update_task', 'brain_update_task',
{ {
description: 'Update an existing task.', description: 'Update an existing task.',
inputSchema: z.object({ inputSchema: strictObject({
id: z.string().describe('Task ID'), id: z.string().describe('Task ID'),
title: z.string().optional(), title: z.string().optional(),
description: z.string().optional(), description: z.string().optional(),
@@ -171,9 +485,17 @@ export class McpService implements OnModuleDestroy {
.optional() .optional()
.describe('not-started, in-progress, blocked, done, cancelled'), .describe('not-started, in-progress, blocked, done, cancelled'),
priority: z.string().optional(), priority: z.string().optional(),
projectId: z.string().optional().describe('Project ID'),
missionId: z.string().optional().describe('Mission ID'),
}), }),
}, },
async ({ id, ...updates }) => { 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 TaskStatus = 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
type Priority = 'low' | 'medium' | 'high' | 'critical'; type Priority = 'low' | 'medium' | 'high' | 'critical';
const task = await this.brain.tasks.update(id, { const task = await this.brain.tasks.update(id, {
@@ -198,14 +520,19 @@ export class McpService implements OnModuleDestroy {
'brain_list_missions', 'brain_list_missions',
{ {
description: 'List all missions, optionally filtered by project.', description: 'List all missions, optionally filtered by project.',
inputSchema: z.object({ inputSchema: strictObject({
projectId: z.string().optional().describe('Filter by project ID'), projectId: z.string().optional().describe('Filter by project ID'),
}), }),
}, },
async ({ projectId }) => { async (params) => {
const missions = projectId 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.findByProject(projectId)
: await this.brain.missions.findAll(); : await this.brain.missions.findAll()) as MissionLike[],
);
return { content: [{ type: 'text' as const, text: JSON.stringify(missions, null, 2) }] }; return { content: [{ type: 'text' as const, text: JSON.stringify(missions, null, 2) }] };
}, },
); );
@@ -213,13 +540,12 @@ export class McpService implements OnModuleDestroy {
server.registerTool( server.registerTool(
'brain_list_conversations', 'brain_list_conversations',
{ {
description: 'List conversations for a user.', description: 'List conversations for the authenticated MCP actor.',
inputSchema: z.object({ inputSchema: strictObject({}),
userId: z.string().describe('User ID'),
}),
}, },
async ({ userId }) => { async (params) => {
const conversations = await this.brain.conversations.findAll(userId); assertMcpToolAuthorized(actor, 'brain_list_conversations', params);
const conversations = await this.brain.conversations.findAll(actor.userId);
return { return {
content: [{ type: 'text' as const, text: JSON.stringify(conversations, null, 2) }], content: [{ type: 'text' as const, text: JSON.stringify(conversations, null, 2) }],
}; };
@@ -232,14 +558,15 @@ export class McpService implements OnModuleDestroy {
'memory_search', 'memory_search',
{ {
description: description:
'Search across stored insights and knowledge using natural language. Returns semantically similar results.', 'Search stored insights and knowledge for the authenticated MCP actor using natural language.',
inputSchema: z.object({ inputSchema: strictObject({
userId: z.string().describe('User ID to search memory for'),
query: z.string().describe('Natural language search query'), query: z.string().describe('Natural language search query'),
limit: z.number().optional().describe('Max results (default 5)'), 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) { if (!this.embeddings.available) {
return { return {
content: [ content: [
@@ -251,7 +578,11 @@ export class McpService implements OnModuleDestroy {
}; };
} }
const embedding = await this.embeddings.embed(query); 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) }] }; return { content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }] };
}, },
); );
@@ -259,20 +590,21 @@ export class McpService implements OnModuleDestroy {
server.registerTool( server.registerTool(
'memory_get_preferences', 'memory_get_preferences',
{ {
description: 'Retrieve stored preferences for a user.', description: 'Retrieve stored preferences for the authenticated MCP actor.',
inputSchema: z.object({ inputSchema: strictObject({
userId: z.string().describe('User ID'),
category: z category: z
.string() .string()
.optional() .optional()
.describe('Filter by category: communication, coding, workflow, appearance, general'), .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'; type Cat = 'communication' | 'coding' | 'workflow' | 'appearance' | 'general';
const prefs = category const prefs = category
? await this.memory.preferences.findByUserAndCategory(userId, category as Cat) ? await this.memory.preferences.findByUserAndCategory(actor.userId, category as Cat)
: await this.memory.preferences.findByUser(userId); : await this.memory.preferences.findByUser(actor.userId);
return { content: [{ type: 'text' as const, text: JSON.stringify(prefs, null, 2) }] }; return { content: [{ type: 'text' as const, text: JSON.stringify(prefs, null, 2) }] };
}, },
); );
@@ -281,9 +613,8 @@ export class McpService implements OnModuleDestroy {
'memory_save_preference', 'memory_save_preference',
{ {
description: description:
'Store a learned user preference (e.g., "prefers tables over paragraphs", "timezone: America/Chicago").', 'Store a learned preference for the authenticated MCP actor (e.g., "prefers tables over paragraphs").',
inputSchema: z.object({ inputSchema: strictObject({
userId: z.string().describe('User ID'),
key: z.string().describe('Preference key'), key: z.string().describe('Preference key'),
value: z.string().describe('Preference value (JSON string)'), value: z.string().describe('Preference value (JSON string)'),
category: z category: z
@@ -292,7 +623,9 @@ export class McpService implements OnModuleDestroy {
.describe('Category: communication, coding, workflow, appearance, general'), .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'; type Cat = 'communication' | 'coding' | 'workflow' | 'appearance' | 'general';
let parsedValue: unknown; let parsedValue: unknown;
try { try {
@@ -301,7 +634,7 @@ export class McpService implements OnModuleDestroy {
parsedValue = value; parsedValue = value;
} }
const pref = await this.memory.preferences.upsert({ const pref = await this.memory.preferences.upsert({
userId, userId: actor.userId,
key, key,
value: parsedValue, value: parsedValue,
category: (category as Cat) ?? 'general', category: (category as Cat) ?? 'general',
@@ -315,9 +648,8 @@ export class McpService implements OnModuleDestroy {
'memory_save_insight', 'memory_save_insight',
{ {
description: description:
'Store a learned insight, decision, or knowledge extracted from the current interaction.', 'Store a learned insight, decision, or knowledge for the authenticated MCP actor.',
inputSchema: z.object({ inputSchema: strictObject({
userId: z.string().describe('User ID'),
content: z.string().describe('The insight or knowledge to store'), content: z.string().describe('The insight or knowledge to store'),
category: z category: z
.string() .string()
@@ -325,11 +657,13 @@ export class McpService implements OnModuleDestroy {
.describe('Category: decision, learning, preference, fact, pattern, general'), .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'; type Cat = 'decision' | 'learning' | 'preference' | 'fact' | 'pattern' | 'general';
const embedding = this.embeddings.available ? await this.embeddings.embed(content) : null; const embedding = this.embeddings.available ? await this.embeddings.embed(content) : null;
const insight = await this.memory.insights.create({ const insight = await this.memory.insights.create({
userId, userId: actor.userId,
content, content,
embedding, embedding,
source: 'agent', source: 'agent',
@@ -346,16 +680,11 @@ export class McpService implements OnModuleDestroy {
{ {
description: description:
'Get the current orchestration mission status including milestones, tasks, and active session.', 'Get the current orchestration mission status including milestones, tasks, and active session.',
inputSchema: z.object({ inputSchema: strictObject({}),
projectPath: z
.string()
.optional()
.describe('Project path. Defaults to gateway working directory.'),
}),
}, },
async ({ projectPath }) => { async (params) => {
const resolvedPath = projectPath ?? process.cwd(); assertMcpToolAuthorized(actor, 'coord_mission_status', params);
const status = await this.coordService.getMissionStatus(resolvedPath); const status = await this.coordService.getMissionStatus(process.cwd());
return { return {
content: [ content: [
{ {
@@ -371,16 +700,11 @@ export class McpService implements OnModuleDestroy {
'coord_list_tasks', 'coord_list_tasks',
{ {
description: 'List all tasks from the orchestration TASKS.md file.', description: 'List all tasks from the orchestration TASKS.md file.',
inputSchema: z.object({ inputSchema: strictObject({}),
projectPath: z
.string()
.optional()
.describe('Project path. Defaults to gateway working directory.'),
}),
}, },
async ({ projectPath }) => { async (params) => {
const resolvedPath = projectPath ?? process.cwd(); assertMcpToolAuthorized(actor, 'coord_list_tasks', params);
const tasks = await this.coordService.listTasks(resolvedPath); const tasks = await this.coordService.listTasks(process.cwd());
return { content: [{ type: 'text' as const, text: JSON.stringify(tasks, null, 2) }] }; return { content: [{ type: 'text' as const, text: JSON.stringify(tasks, null, 2) }] };
}, },
); );
@@ -389,17 +713,14 @@ export class McpService implements OnModuleDestroy {
'coord_task_detail', 'coord_task_detail',
{ {
description: 'Get detailed status for a specific orchestration task.', description: 'Get detailed status for a specific orchestration task.',
inputSchema: z.object({ inputSchema: strictObject({
taskId: z.string().describe('Task ID (e.g. P2-005)'), 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 }) => { async (params) => {
const resolvedPath = projectPath ?? process.cwd(); assertMcpToolAuthorized(actor, 'coord_task_detail', params);
const detail = await this.coordService.getTaskStatus(resolvedPath, taskId); const { taskId } = params;
const detail = await this.coordService.getTaskStatus(process.cwd(), taskId);
return { return {
content: [ content: [
{ {