Skip to content

Commit 2ae2de4

Browse files
authored
Merge pull request #287 from ShipSecAI/eng-191/studio-mcp-server
feat(backend): expose Studio as Streamable HTTP MCP server
2 parents 7022c1b + 4377d6f commit 2ae2de4

12 files changed

Lines changed: 1486 additions & 1 deletion

backend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@
7676
"@typescript-eslint/eslint-plugin": "^8.53.1",
7777
"@typescript-eslint/parser": "^8.53.1",
7878
"bun-types": "^1.3.6",
79+
"cookie-parser": "^1.4.7",
7980
"drizzle-kit": "^0.31.8",
8081
"eslint": "^9.39.2",
8182
"eslint-config-prettier": "^10.1.8",

backend/src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { IntegrationsModule } from './integrations/integrations.module';
2525
import { SchedulesModule } from './schedules/schedules.module';
2626
import { AnalyticsModule } from './analytics/analytics.module';
2727
import { McpModule } from './mcp/mcp.module';
28+
import { StudioMcpModule } from './studio-mcp/studio-mcp.module';
2829

2930
import { ApiKeysModule } from './api-keys/api-keys.module';
3031
import { WebhooksModule } from './webhooks/webhooks.module';
@@ -49,6 +50,7 @@ const coreModules = [
4950
McpServersModule,
5051
McpGroupsModule,
5152
McpModule,
53+
StudioMcpModule,
5254
];
5355

5456
const testingModules = process.env.NODE_ENV === 'production' ? [] : [TestingSupportModule];

backend/src/auth/auth.guard.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ export class AuthGuard implements CanActivate {
124124
roles: ['MEMBER'], // API keys have MEMBER role by default
125125
isAuthenticated: true,
126126
provider: 'api-key',
127+
apiKeyPermissions: apiKey.permissions,
127128
};
128129
}
129130
}

backend/src/auth/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
11
export type AuthRole = 'ADMIN' | 'MEMBER';
22

3+
export interface ApiKeyPermissions {
4+
workflows: { run: boolean; list: boolean; read: boolean };
5+
runs: { read: boolean; cancel: boolean };
6+
}
7+
38
export interface AuthContext {
49
userId: string | null;
510
organizationId: string | null;
611
roles: AuthRole[];
712
isAuthenticated: boolean;
813
provider: string;
14+
/** Present only when authenticated via API key. */
15+
apiKeyPermissions?: ApiKeyPermissions;
916
}
1017

1118
export const DEFAULT_ROLES: AuthRole[] = ['ADMIN', 'MEMBER'];
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
import { describe, it, expect, beforeEach, jest } from 'bun:test';
2+
import { StudioMcpController } from '../studio-mcp.controller';
3+
import type { StudioMcpService } from '../studio-mcp.service';
4+
import type { AuthContext } from '../../auth/types';
5+
import type { Request, Response } from 'express';
6+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
7+
8+
// Access private sessions map for assertions
9+
type SessionsMap = Map<
10+
string,
11+
{ transport: unknown; userId: string | null; organizationId: string | null }
12+
>;
13+
function getSessions(controller: StudioMcpController): SessionsMap {
14+
return (controller as unknown as { sessions: SessionsMap }).sessions;
15+
}
16+
17+
function createMockRes(): Response & { _status: number; _json: unknown } {
18+
const res = {
19+
_status: 200,
20+
_json: null,
21+
status(code: number) {
22+
res._status = code;
23+
return res;
24+
},
25+
json(body: unknown) {
26+
res._json = body;
27+
return res;
28+
},
29+
on: jest.fn(),
30+
} as unknown as Response & { _status: number; _json: unknown };
31+
return res;
32+
}
33+
34+
function createMockReq(
35+
overrides: Partial<Request> & { auth?: AuthContext } = {},
36+
): Request & { auth?: AuthContext } {
37+
return {
38+
method: 'POST',
39+
headers: {},
40+
header: jest.fn().mockReturnValue(undefined),
41+
body: {},
42+
...overrides,
43+
} as unknown as Request & { auth?: AuthContext };
44+
}
45+
46+
describe('StudioMcpController', () => {
47+
let controller: StudioMcpController;
48+
let mcpService: StudioMcpService;
49+
50+
const authUser1: AuthContext = {
51+
userId: 'user-1',
52+
organizationId: 'org-1',
53+
roles: ['MEMBER'],
54+
isAuthenticated: true,
55+
provider: 'api-key',
56+
apiKeyPermissions: {
57+
workflows: { run: true, list: true, read: true },
58+
runs: { read: true, cancel: true },
59+
},
60+
};
61+
62+
const authUser2: AuthContext = {
63+
userId: 'user-2',
64+
organizationId: 'org-2',
65+
roles: ['MEMBER'],
66+
isAuthenticated: true,
67+
provider: 'api-key',
68+
apiKeyPermissions: {
69+
workflows: { run: true, list: true, read: true },
70+
runs: { read: true, cancel: true },
71+
},
72+
};
73+
74+
beforeEach(() => {
75+
mcpService = {
76+
createServer: jest.fn().mockReturnValue(new McpServer({ name: 'test', version: '1.0.0' })),
77+
} as unknown as StudioMcpService;
78+
79+
controller = new StudioMcpController(mcpService);
80+
});
81+
82+
it('rejects unauthenticated requests with 401', async () => {
83+
const req = createMockReq({ auth: undefined });
84+
const res = createMockRes();
85+
86+
await controller.handleMcp(req, res);
87+
88+
expect(res._status).toBe(401);
89+
expect(res._json).toEqual({
90+
error: 'Authentication required. Use Bearer sk_live_* API key.',
91+
});
92+
});
93+
94+
it('rejects requests without session ID and without initialize body with 400', async () => {
95+
const req = createMockReq({
96+
auth: authUser1,
97+
method: 'POST',
98+
headers: {},
99+
body: { jsonrpc: '2.0', method: 'tools/list', id: 1 },
100+
});
101+
const res = createMockRes();
102+
103+
await controller.handleMcp(req, res);
104+
105+
expect(res._status).toBe(400);
106+
});
107+
108+
it('returns 404 for unknown session ID', async () => {
109+
const req = createMockReq({
110+
auth: authUser1,
111+
headers: { 'mcp-session-id': 'nonexistent-session' },
112+
});
113+
const res = createMockRes();
114+
115+
await controller.handleMcp(req, res);
116+
117+
expect(res._status).toBe(404);
118+
expect(res._json).toEqual({ error: 'Session not found or expired' });
119+
});
120+
121+
describe('session identity binding', () => {
122+
it('rejects session reuse from different user with 403', async () => {
123+
// Manually insert a session owned by user-1
124+
const sessions = getSessions(controller);
125+
const mockTransport = { handleRequest: jest.fn() };
126+
sessions.set('test-session-id', {
127+
transport: mockTransport,
128+
userId: authUser1.userId,
129+
organizationId: authUser1.organizationId,
130+
});
131+
132+
// User-2 tries to use user-1's session
133+
const req = createMockReq({
134+
auth: authUser2,
135+
method: 'POST',
136+
headers: { 'mcp-session-id': 'test-session-id' },
137+
body: { jsonrpc: '2.0', method: 'tools/list', id: 1 },
138+
});
139+
const res = createMockRes();
140+
141+
await controller.handleMcp(req, res);
142+
143+
expect(res._status).toBe(403);
144+
expect(res._json).toEqual({ error: 'Session belongs to a different principal' });
145+
expect(mockTransport.handleRequest).not.toHaveBeenCalled();
146+
});
147+
148+
it('rejects session reuse from different org with 403', async () => {
149+
const sessions = getSessions(controller);
150+
const mockTransport = { handleRequest: jest.fn() };
151+
sessions.set('test-session-id', {
152+
transport: mockTransport,
153+
userId: authUser1.userId,
154+
organizationId: authUser1.organizationId,
155+
});
156+
157+
// Same user ID but different org
158+
const crossOrgAuth: AuthContext = {
159+
...authUser1,
160+
organizationId: 'different-org',
161+
};
162+
const req = createMockReq({
163+
auth: crossOrgAuth,
164+
method: 'POST',
165+
headers: { 'mcp-session-id': 'test-session-id' },
166+
body: { jsonrpc: '2.0', method: 'tools/list', id: 1 },
167+
});
168+
const res = createMockRes();
169+
170+
await controller.handleMcp(req, res);
171+
172+
expect(res._status).toBe(403);
173+
expect(mockTransport.handleRequest).not.toHaveBeenCalled();
174+
});
175+
176+
it('allows session reuse from same principal', async () => {
177+
const sessions = getSessions(controller);
178+
const mockTransport = { handleRequest: jest.fn() };
179+
sessions.set('test-session-id', {
180+
transport: mockTransport,
181+
userId: authUser1.userId,
182+
organizationId: authUser1.organizationId,
183+
});
184+
185+
const req = createMockReq({
186+
auth: authUser1,
187+
method: 'POST',
188+
headers: { 'mcp-session-id': 'test-session-id' },
189+
body: { jsonrpc: '2.0', method: 'tools/list', id: 1 },
190+
});
191+
const res = createMockRes();
192+
193+
await controller.handleMcp(req, res);
194+
195+
// Should have forwarded to the transport, not returned an error
196+
expect(res._status).toBe(200); // not changed to 403 or 404
197+
expect(mockTransport.handleRequest).toHaveBeenCalled();
198+
});
199+
200+
it('cleans up session on DELETE from same principal', async () => {
201+
const sessions = getSessions(controller);
202+
const mockTransport = { handleRequest: jest.fn() };
203+
sessions.set('test-session-id', {
204+
transport: mockTransport,
205+
userId: authUser1.userId,
206+
organizationId: authUser1.organizationId,
207+
});
208+
209+
const req = createMockReq({
210+
auth: authUser1,
211+
method: 'DELETE',
212+
headers: { 'mcp-session-id': 'test-session-id' },
213+
});
214+
const res = createMockRes();
215+
216+
await controller.handleMcp(req, res);
217+
218+
expect(sessions.has('test-session-id')).toBe(false);
219+
expect(mockTransport.handleRequest).toHaveBeenCalled();
220+
});
221+
});
222+
});

0 commit comments

Comments
 (0)