Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
"@typescript-eslint/eslint-plugin": "^8.53.1",
"@typescript-eslint/parser": "^8.53.1",
"bun-types": "^1.3.6",
"cookie-parser": "^1.4.7",
"drizzle-kit": "^0.31.8",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
Expand Down
2 changes: 2 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { IntegrationsModule } from './integrations/integrations.module';
import { SchedulesModule } from './schedules/schedules.module';
import { AnalyticsModule } from './analytics/analytics.module';
import { McpModule } from './mcp/mcp.module';
import { StudioMcpModule } from './studio-mcp/studio-mcp.module';

import { ApiKeysModule } from './api-keys/api-keys.module';
import { WebhooksModule } from './webhooks/webhooks.module';
Expand All @@ -49,6 +50,7 @@ const coreModules = [
McpServersModule,
McpGroupsModule,
McpModule,
StudioMcpModule,
];

const testingModules = process.env.NODE_ENV === 'production' ? [] : [TestingSupportModule];
Expand Down
1 change: 1 addition & 0 deletions backend/src/auth/auth.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ export class AuthGuard implements CanActivate {
roles: ['MEMBER'], // API keys have MEMBER role by default
isAuthenticated: true,
provider: 'api-key',
apiKeyPermissions: apiKey.permissions,
};
}
}
7 changes: 7 additions & 0 deletions backend/src/auth/types.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
export type AuthRole = 'ADMIN' | 'MEMBER';

export interface ApiKeyPermissions {
workflows: { run: boolean; list: boolean; read: boolean };
runs: { read: boolean; cancel: boolean };
}

export interface AuthContext {
userId: string | null;
organizationId: string | null;
roles: AuthRole[];
isAuthenticated: boolean;
provider: string;
/** Present only when authenticated via API key. */
apiKeyPermissions?: ApiKeyPermissions;
}

export const DEFAULT_ROLES: AuthRole[] = ['ADMIN', 'MEMBER'];
222 changes: 222 additions & 0 deletions backend/src/studio-mcp/__tests__/studio-mcp.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import { describe, it, expect, beforeEach, jest } from 'bun:test';
import { StudioMcpController } from '../studio-mcp.controller';
import type { StudioMcpService } from '../studio-mcp.service';
import type { AuthContext } from '../../auth/types';
import type { Request, Response } from 'express';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';

// Access private sessions map for assertions
type SessionsMap = Map<
string,
{ transport: unknown; userId: string | null; organizationId: string | null }
>;
function getSessions(controller: StudioMcpController): SessionsMap {
return (controller as unknown as { sessions: SessionsMap }).sessions;
}

function createMockRes(): Response & { _status: number; _json: unknown } {
const res = {
_status: 200,
_json: null,
status(code: number) {
res._status = code;
return res;
},
json(body: unknown) {
res._json = body;
return res;
},
on: jest.fn(),
} as unknown as Response & { _status: number; _json: unknown };
return res;
}

function createMockReq(
overrides: Partial<Request> & { auth?: AuthContext } = {},
): Request & { auth?: AuthContext } {
return {
method: 'POST',
headers: {},
header: jest.fn().mockReturnValue(undefined),
body: {},
...overrides,
} as unknown as Request & { auth?: AuthContext };
}

describe('StudioMcpController', () => {
let controller: StudioMcpController;
let mcpService: StudioMcpService;

const authUser1: AuthContext = {
userId: 'user-1',
organizationId: 'org-1',
roles: ['MEMBER'],
isAuthenticated: true,
provider: 'api-key',
apiKeyPermissions: {
workflows: { run: true, list: true, read: true },
runs: { read: true, cancel: true },
},
};

const authUser2: AuthContext = {
userId: 'user-2',
organizationId: 'org-2',
roles: ['MEMBER'],
isAuthenticated: true,
provider: 'api-key',
apiKeyPermissions: {
workflows: { run: true, list: true, read: true },
runs: { read: true, cancel: true },
},
};

beforeEach(() => {
mcpService = {
createServer: jest.fn().mockReturnValue(new McpServer({ name: 'test', version: '1.0.0' })),
} as unknown as StudioMcpService;

controller = new StudioMcpController(mcpService);
});

it('rejects unauthenticated requests with 401', async () => {
const req = createMockReq({ auth: undefined });
const res = createMockRes();

await controller.handleMcp(req, res);

expect(res._status).toBe(401);
expect(res._json).toEqual({
error: 'Authentication required. Use Bearer sk_live_* API key.',
});
});

it('rejects requests without session ID and without initialize body with 400', async () => {
const req = createMockReq({
auth: authUser1,
method: 'POST',
headers: {},
body: { jsonrpc: '2.0', method: 'tools/list', id: 1 },
});
const res = createMockRes();

await controller.handleMcp(req, res);

expect(res._status).toBe(400);
});

it('returns 404 for unknown session ID', async () => {
const req = createMockReq({
auth: authUser1,
headers: { 'mcp-session-id': 'nonexistent-session' },
});
const res = createMockRes();

await controller.handleMcp(req, res);

expect(res._status).toBe(404);
expect(res._json).toEqual({ error: 'Session not found or expired' });
});

describe('session identity binding', () => {
it('rejects session reuse from different user with 403', async () => {
// Manually insert a session owned by user-1
const sessions = getSessions(controller);
const mockTransport = { handleRequest: jest.fn() };
sessions.set('test-session-id', {
transport: mockTransport,
userId: authUser1.userId,
organizationId: authUser1.organizationId,
});

// User-2 tries to use user-1's session
const req = createMockReq({
auth: authUser2,
method: 'POST',
headers: { 'mcp-session-id': 'test-session-id' },
body: { jsonrpc: '2.0', method: 'tools/list', id: 1 },
});
const res = createMockRes();

await controller.handleMcp(req, res);

expect(res._status).toBe(403);
expect(res._json).toEqual({ error: 'Session belongs to a different principal' });
expect(mockTransport.handleRequest).not.toHaveBeenCalled();
});

it('rejects session reuse from different org with 403', async () => {
const sessions = getSessions(controller);
const mockTransport = { handleRequest: jest.fn() };
sessions.set('test-session-id', {
transport: mockTransport,
userId: authUser1.userId,
organizationId: authUser1.organizationId,
});

// Same user ID but different org
const crossOrgAuth: AuthContext = {
...authUser1,
organizationId: 'different-org',
};
const req = createMockReq({
auth: crossOrgAuth,
method: 'POST',
headers: { 'mcp-session-id': 'test-session-id' },
body: { jsonrpc: '2.0', method: 'tools/list', id: 1 },
});
const res = createMockRes();

await controller.handleMcp(req, res);

expect(res._status).toBe(403);
expect(mockTransport.handleRequest).not.toHaveBeenCalled();
});

it('allows session reuse from same principal', async () => {
const sessions = getSessions(controller);
const mockTransport = { handleRequest: jest.fn() };
sessions.set('test-session-id', {
transport: mockTransport,
userId: authUser1.userId,
organizationId: authUser1.organizationId,
});

const req = createMockReq({
auth: authUser1,
method: 'POST',
headers: { 'mcp-session-id': 'test-session-id' },
body: { jsonrpc: '2.0', method: 'tools/list', id: 1 },
});
const res = createMockRes();

await controller.handleMcp(req, res);

// Should have forwarded to the transport, not returned an error
expect(res._status).toBe(200); // not changed to 403 or 404
expect(mockTransport.handleRequest).toHaveBeenCalled();
});

it('cleans up session on DELETE from same principal', async () => {
const sessions = getSessions(controller);
const mockTransport = { handleRequest: jest.fn() };
sessions.set('test-session-id', {
transport: mockTransport,
userId: authUser1.userId,
organizationId: authUser1.organizationId,
});

const req = createMockReq({
auth: authUser1,
method: 'DELETE',
headers: { 'mcp-session-id': 'test-session-id' },
});
const res = createMockRes();

await controller.handleMcp(req, res);

expect(sessions.has('test-session-id')).toBe(false);
expect(mockTransport.handleRequest).toHaveBeenCalled();
});
});
});
Loading