462 lines
17 KiB
TypeScript
462 lines
17 KiB
TypeScript
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();
|
|
});
|
|
});
|