Skip to content

Commit ffa41b3

Browse files
devartifexCopilot
andcommitted
fix: complete MCP server integration (#51)
- Extract parseMcpServers() helper with defense-in-depth enabled filtering - Pass MCP servers (GitHub + user) on resume_session (SDK + fallback) - Update ResumeSessionMessage type to include mcpServers - Client sends enabled MCP servers when resuming sessions - Add unit tests for MCP parser (9 tests) and session config (3 tests) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 56c38c9 commit ffa41b3

6 files changed

Lines changed: 227 additions & 4 deletions

File tree

src/lib/server/copilot/session.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,75 @@ describe('createCopilotSession', () => {
400400
expect(config.customAgents).toEqual(customAgents);
401401
});
402402

403+
it('supports SSE-type MCP servers alongside HTTP servers', async () => {
404+
const client = createClientMock();
405+
406+
await createCopilotSession(client as unknown as Parameters<typeof createCopilotSession>[0], 'gh-token', {
407+
mcpServers: [
408+
{
409+
name: 'http-server',
410+
url: 'https://api.example.com/mcp',
411+
type: 'http',
412+
headers: { 'X-Api-Key': 'key1' },
413+
tools: ['search'],
414+
},
415+
{
416+
name: 'sse-server',
417+
url: 'https://stream.example.com/events',
418+
type: 'sse',
419+
headers: { Authorization: 'Bearer tok' },
420+
tools: [],
421+
},
422+
],
423+
});
424+
425+
const mcpServers = getSessionConfig(client).mcpServers as Record<string, Record<string, unknown>>;
426+
427+
// GitHub server always present
428+
expect(mcpServers.github).toBeDefined();
429+
430+
// HTTP server preserved as-is
431+
expect(mcpServers['http-server']).toEqual({
432+
type: 'http',
433+
url: 'https://api.example.com/mcp',
434+
headers: { 'X-Api-Key': 'key1' },
435+
tools: ['search'],
436+
});
437+
438+
// SSE server with empty tools defaults to wildcard
439+
expect(mcpServers['sse-server']).toEqual({
440+
type: 'sse',
441+
url: 'https://stream.example.com/events',
442+
headers: { Authorization: 'Bearer tok' },
443+
tools: ['*'],
444+
});
445+
});
446+
447+
it('always includes GitHub MCP server with the provided token', async () => {
448+
const client = createClientMock();
449+
450+
await createCopilotSession(client as unknown as Parameters<typeof createCopilotSession>[0], 'my-gh-token-123');
451+
452+
const mcpServers = getSessionConfig(client).mcpServers as Record<string, Record<string, unknown>>;
453+
expect(mcpServers.github).toEqual({
454+
type: 'http',
455+
url: 'https://api.githubcopilot.com/mcp/x/all',
456+
headers: { Authorization: 'Bearer my-gh-token-123' },
457+
tools: ['*'],
458+
});
459+
});
460+
461+
it('omits user MCP servers when none are provided', async () => {
462+
const client = createClientMock();
463+
464+
await createCopilotSession(client as unknown as Parameters<typeof createCopilotSession>[0], 'gh-token', {
465+
mcpServers: [],
466+
});
467+
468+
const mcpServers = getSessionConfig(client).mcpServers as Record<string, Record<string, unknown>>;
469+
expect(Object.keys(mcpServers)).toEqual(['github']);
470+
});
471+
403472
it('wires session hooks when onHookEvent callback is provided', async () => {
404473
const client = createClientMock();
405474
const onHookEvent = vi.fn();

src/lib/server/ws/handler.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,36 @@ export function isValidAttachmentPath(filePath: string): boolean {
4141
return resolved.startsWith(UPLOAD_DIR_PREFIX + '/');
4242
}
4343

44+
/** Parse and validate MCP server entries from a WebSocket message, filtering out disabled servers. */
45+
export function parseMcpServers(raw: unknown): Array<{ name: string; url: string; type: 'http' | 'sse'; headers: Record<string, string>; tools: string[] }> | undefined {
46+
if (!Array.isArray(raw)) return undefined;
47+
const servers = raw
48+
.filter((s: unknown) => {
49+
if (!s || typeof s !== 'object') return false;
50+
const obj = s as Record<string, unknown>;
51+
if (obj.enabled === false) return false;
52+
return (
53+
typeof obj.name === 'string' &&
54+
typeof obj.url === 'string' &&
55+
(obj.type === 'http' || obj.type === 'sse') &&
56+
typeof obj.headers === 'object' && obj.headers !== null &&
57+
Array.isArray(obj.tools)
58+
);
59+
})
60+
.slice(0, 10)
61+
.map((s: unknown) => {
62+
const obj = s as Record<string, unknown>;
63+
return {
64+
name: obj.name as string,
65+
url: obj.url as string,
66+
type: obj.type as 'http' | 'sse',
67+
headers: obj.headers as Record<string, string>,
68+
tools: (obj.tools as unknown[]).filter((t): t is string => typeof t === 'string'),
69+
};
70+
});
71+
return servers.length > 0 ? servers : undefined;
72+
}
73+
4474
/** Normalize SDK quota snapshots: convert remainingPercentage from 0.0–1.0 to 0–100 and add percentageUsed */
4575
function normalizeQuotaSnapshots(raw: Record<string, any> | undefined): Record<string, any> | undefined {
4676
if (!raw) return raw;
@@ -1058,6 +1088,29 @@ export function setupWebSocket(
10581088
// Read filesystem plan for injection into resumed session context
10591089
const detail = await getSessionDetail(sessionId);
10601090

1091+
// Parse MCP servers from the message so resumed sessions retain MCP access
1092+
const resumeMcpServers = parseMcpServers(msg.mcpServers);
1093+
1094+
// Build the full MCP config (GitHub server + user servers)
1095+
const mcpServersConfig: Record<string, unknown> = {
1096+
github: {
1097+
type: 'http',
1098+
url: 'https://api.githubcopilot.com/mcp/x/all',
1099+
headers: { Authorization: `Bearer ${githubToken}` },
1100+
tools: ['*'],
1101+
},
1102+
};
1103+
if (resumeMcpServers) {
1104+
for (const s of resumeMcpServers) {
1105+
mcpServersConfig[s.name] = {
1106+
type: s.type,
1107+
url: s.url,
1108+
headers: s.headers,
1109+
tools: s.tools.length > 0 ? s.tools : ['*'],
1110+
};
1111+
}
1112+
}
1113+
10611114
let resumed = false;
10621115

10631116
// Try native SDK resume first
@@ -1067,6 +1120,7 @@ export function setupWebSocket(
10671120
streaming: true,
10681121
onUserInputRequest: makeUserInputHandler(connectionEntry),
10691122
configDir: resolvedConfigDir,
1123+
mcpServers: mcpServersConfig as any,
10701124
...(detail?.plan && {
10711125
systemMessage: {
10721126
mode: 'append' as const,
@@ -1092,6 +1146,7 @@ export function setupWebSocket(
10921146
onUserInputRequest: makeUserInputHandler(connectionEntry),
10931147
permissionMode: 'approve_all',
10941148
configDir: resolvedConfigDir,
1149+
mcpServers: resumeMcpServers,
10951150
onHookEvent: (message) => poolSend(connectionEntry, message),
10961151
});
10971152
console.log(`[RESUME] Fallback session created for ${sessionId} with context injection`);
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// @vitest-environment node
2+
import { describe, expect, it } from 'vitest';
3+
import { parseMcpServers } from './handler.js';
4+
5+
describe('parseMcpServers', () => {
6+
it('returns undefined for non-array input', () => {
7+
expect(parseMcpServers(undefined)).toBeUndefined();
8+
expect(parseMcpServers(null)).toBeUndefined();
9+
expect(parseMcpServers('string')).toBeUndefined();
10+
expect(parseMcpServers(42)).toBeUndefined();
11+
});
12+
13+
it('returns undefined for an empty array', () => {
14+
expect(parseMcpServers([])).toBeUndefined();
15+
});
16+
17+
it('parses valid HTTP and SSE servers', () => {
18+
const result = parseMcpServers([
19+
{ name: 'api', url: 'https://api.example.com/mcp', type: 'http', headers: { 'X-Key': 'k1' }, tools: ['search'] },
20+
{ name: 'stream', url: 'https://stream.example.com/events', type: 'sse', headers: {}, tools: [] },
21+
]);
22+
23+
expect(result).toEqual([
24+
{ name: 'api', url: 'https://api.example.com/mcp', type: 'http', headers: { 'X-Key': 'k1' }, tools: ['search'] },
25+
{ name: 'stream', url: 'https://stream.example.com/events', type: 'sse', headers: {}, tools: [] },
26+
]);
27+
});
28+
29+
it('filters out servers with enabled === false', () => {
30+
const result = parseMcpServers([
31+
{ name: 'active', url: 'https://a.example.com/mcp', type: 'http', headers: {}, tools: [], enabled: true },
32+
{ name: 'disabled', url: 'https://b.example.com/mcp', type: 'http', headers: {}, tools: [], enabled: false },
33+
]);
34+
35+
expect(result).toEqual([
36+
{ name: 'active', url: 'https://a.example.com/mcp', type: 'http', headers: {}, tools: [] },
37+
]);
38+
});
39+
40+
it('returns undefined when all servers are disabled', () => {
41+
const result = parseMcpServers([
42+
{ name: 's1', url: 'https://a.example.com/mcp', type: 'http', headers: {}, tools: [], enabled: false },
43+
{ name: 's2', url: 'https://b.example.com/mcp', type: 'sse', headers: {}, tools: [], enabled: false },
44+
]);
45+
46+
expect(result).toBeUndefined();
47+
});
48+
49+
it('includes servers without an enabled field (defaults to enabled)', () => {
50+
const result = parseMcpServers([
51+
{ name: 'no-flag', url: 'https://a.example.com/mcp', type: 'http', headers: {}, tools: [] },
52+
]);
53+
54+
expect(result).toHaveLength(1);
55+
expect(result![0].name).toBe('no-flag');
56+
});
57+
58+
it('rejects entries with missing or invalid fields', () => {
59+
const result = parseMcpServers([
60+
null,
61+
{ name: 'missing-url', type: 'http', headers: {}, tools: [] },
62+
{ name: 'bad-type', url: 'https://a.example.com', type: 'grpc', headers: {}, tools: [] },
63+
{ name: 'no-headers', url: 'https://a.example.com', type: 'http', tools: [] },
64+
{ name: 'no-tools', url: 'https://a.example.com', type: 'http', headers: {} },
65+
'not-an-object',
66+
]);
67+
68+
expect(result).toBeUndefined();
69+
});
70+
71+
it('limits to 10 servers', () => {
72+
const servers = Array.from({ length: 15 }, (_, i) => ({
73+
name: `server-${i}`,
74+
url: `https://s${i}.example.com/mcp`,
75+
type: 'http' as const,
76+
headers: {},
77+
tools: [],
78+
}));
79+
80+
const result = parseMcpServers(servers);
81+
expect(result).toHaveLength(10);
82+
expect(result![9].name).toBe('server-9');
83+
});
84+
85+
it('filters non-string values from tools arrays', () => {
86+
const result = parseMcpServers([
87+
{ name: 'mixed-tools', url: 'https://a.example.com/mcp', type: 'http', headers: {}, tools: ['valid', 42, null, 'also-valid'] },
88+
]);
89+
90+
expect(result![0].tools).toEqual(['valid', 'also-valid']);
91+
});
92+
});

src/lib/stores/ws.svelte.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {
66
ServerMessage,
77
NewSessionConfig,
88
MessageDeliveryMode,
9+
McpServerDefinition,
910
} from '$lib/types/index.js';
1011
import { notify } from '$lib/utils/notifications.js';
1112

@@ -40,7 +41,7 @@ export interface WsStore {
4041
mode?: MessageDeliveryMode,
4142
): void;
4243
newSession(config: NewSessionConfig): void;
43-
resumeSession(sessionId: string): void;
44+
resumeSession(sessionId: string, mcpServers?: McpServerDefinition[]): void;
4445
setMode(mode: SessionMode): void;
4546
setModel(model: string): void;
4647
setReasoning(effort: ReasoningEffort): void;
@@ -248,9 +249,14 @@ export function createWsStore(): WsStore {
248249
send(msg);
249250
}
250251

251-
function resumeSession(sessionId: string): void {
252+
function resumeSession(sessionId: string, mcpServers?: McpServerDefinition[]): void {
252253
sessionReady = false;
253-
send({ type: 'resume_session', sessionId });
254+
const enabledServers = mcpServers?.filter(s => s.enabled);
255+
send({
256+
type: 'resume_session',
257+
sessionId,
258+
...(enabledServers?.length && { mcpServers: enabledServers }),
259+
});
254260
}
255261

256262
function setMode(mode: SessionMode): void {

src/lib/types/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -695,6 +695,7 @@ export interface ListSessionsMessage {
695695
export interface ResumeSessionMessage {
696696
type: 'resume_session';
697697
sessionId: string;
698+
mcpServers?: McpServerDefinition[];
698699
}
699700

700701
export interface DeleteSessionMessage {

src/routes/+page.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@
207207
208208
function handleResumeSession(sessionId: string): void {
209209
chatStore.clearMessages();
210-
wsStore.resumeSession(sessionId);
210+
wsStore.resumeSession(sessionId, settings.mcpServers);
211211
sessionsOpen = false;
212212
}
213213

0 commit comments

Comments
 (0)