Skip to content

Commit faa2a55

Browse files
committed
test(runtime): cover Intelligence MCP auto-attach via forwardedProps
Four cases at the agent layer (BuiltInAgent reading forwardedProps): * attaches when `copilotkitIntelligence` carries all three strings (userId, apiKey, mcpUrl) — outbound headers carry Authorization + X-Cpki-User-Id. * does NOT attach when the bag is absent (no Intelligence wiring on this run). * does NOT attach when the bag is partial (e.g. mcpUrl missing). * does NOT attach when the user has already configured an MCP server pointing at the same URL — explicit user config wins, with the user's headers and resolver hitting the wire.
1 parent a46acc3 commit faa2a55

1 file changed

Lines changed: 239 additions & 0 deletions

File tree

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2+
import { BasicAgent } from "../../../../agent";
3+
import { LLMock, MCPMock } from "@copilotkit/aimock";
4+
import { streamText } from "ai";
5+
import {
6+
mockStreamTextResponse,
7+
textDelta,
8+
finish,
9+
collectEvents,
10+
} from "../../../../agent/__tests__/test-helpers";
11+
12+
vi.mock("ai", () => ({
13+
streamText: vi.fn(),
14+
tool: vi.fn((config) => config),
15+
stepCountIs: vi.fn((count: number) => ({ type: "stepCount", count })),
16+
}));
17+
18+
vi.mock("@ai-sdk/openai", () => ({
19+
createOpenAI: vi.fn(() => (modelId: string) => ({
20+
modelId,
21+
provider: "openai",
22+
})),
23+
}));
24+
25+
async function startMcpMock(): Promise<{ url: string; server: LLMock }> {
26+
const mock = new MCPMock();
27+
mock.addTool({
28+
name: "bash",
29+
description: "Run a bash command",
30+
inputSchema: {
31+
type: "object",
32+
properties: { command: { type: "string" } },
33+
},
34+
});
35+
mock.onToolCall("bash", () => "ok");
36+
const server = new LLMock({ port: 0 });
37+
server.mount("/mcp", mock);
38+
await server.start();
39+
return { url: server.url, server };
40+
}
41+
42+
/**
43+
* aimock redacts `Authorization` to `[REDACTED]` in its journal. Spy on
44+
* `globalThis.fetch` to read unredacted headers off each outbound request to
45+
* `mcpUrl`. The spy delegates to the real fetch so the round-trip completes.
46+
*/
47+
function spyOnFetch(mcpUrl: string): {
48+
records: Array<Record<string, string>>;
49+
restore: () => void;
50+
} {
51+
const records: Array<Record<string, string>> = [];
52+
const realFetch = globalThis.fetch;
53+
const spy = vi
54+
.spyOn(globalThis, "fetch")
55+
.mockImplementation(async (input, init) => {
56+
const url =
57+
typeof input === "string"
58+
? input
59+
: input instanceof URL
60+
? input.toString()
61+
: input.url;
62+
if (url.startsWith(mcpUrl)) {
63+
const seen: Record<string, string> = {};
64+
new Headers(init?.headers ?? {}).forEach((value, key) => {
65+
seen[key.toLowerCase()] = value;
66+
});
67+
records.push(seen);
68+
}
69+
return realFetch(input, init);
70+
});
71+
return { records, restore: () => spy.mockRestore() };
72+
}
73+
74+
const baseInput = {
75+
threadId: "thread1",
76+
runId: "run1",
77+
messages: [],
78+
tools: [],
79+
context: [],
80+
state: {},
81+
};
82+
83+
describe("BuiltInAgent — Intelligence MCP auto-attach via forwardedProps", () => {
84+
let llm: LLMock | undefined;
85+
const originalEnv = process.env;
86+
87+
beforeEach(() => {
88+
vi.clearAllMocks();
89+
process.env = { ...originalEnv };
90+
process.env.OPENAI_API_KEY = "test-key";
91+
});
92+
93+
afterEach(async () => {
94+
process.env = originalEnv;
95+
if (llm) {
96+
await llm.stop().catch(() => {});
97+
llm = undefined;
98+
}
99+
});
100+
101+
it("attaches the Intelligence MCP server when forwardedProps carries userId + apiKey + mcpUrl", async () => {
102+
const { url, server } = await startMcpMock();
103+
llm = server;
104+
105+
const recorder = spyOnFetch(`${url}/mcp`);
106+
try {
107+
const agent = new BasicAgent({ model: "openai/gpt-4o" });
108+
109+
vi.mocked(streamText).mockReturnValue(
110+
mockStreamTextResponse([textDelta("hi"), finish()]) as any,
111+
);
112+
113+
await collectEvents(
114+
agent["run"]({
115+
...baseInput,
116+
forwardedProps: {
117+
copilotkitIntelligence: {
118+
userId: "jordan-beamson",
119+
apiKey: "cpk-proj_short_long",
120+
mcpUrl: `${url}/mcp`,
121+
},
122+
},
123+
}),
124+
);
125+
126+
expect(recorder.records.length).toBeGreaterThan(0);
127+
for (const headers of recorder.records) {
128+
expect(headers["authorization"]).toBe("Bearer cpk-proj_short_long");
129+
expect(headers["x-cpki-user-id"]).toBe("jordan-beamson");
130+
}
131+
} finally {
132+
recorder.restore();
133+
}
134+
});
135+
136+
it("does NOT attach when forwardedProps is empty (no Intelligence wiring this run)", async () => {
137+
const { url, server } = await startMcpMock();
138+
llm = server;
139+
140+
const recorder = spyOnFetch(`${url}/mcp`);
141+
try {
142+
const agent = new BasicAgent({ model: "openai/gpt-4o" });
143+
144+
vi.mocked(streamText).mockReturnValue(
145+
mockStreamTextResponse([finish()]) as any,
146+
);
147+
await collectEvents(agent["run"](baseInput));
148+
149+
expect(recorder.records.length).toBe(0);
150+
} finally {
151+
recorder.restore();
152+
}
153+
});
154+
155+
it("does NOT attach when only some of the three props are present", async () => {
156+
const { url, server } = await startMcpMock();
157+
llm = server;
158+
159+
const recorder = spyOnFetch(`${url}/mcp`);
160+
try {
161+
const agent = new BasicAgent({ model: "openai/gpt-4o" });
162+
163+
vi.mocked(streamText).mockReturnValue(
164+
mockStreamTextResponse([finish()]) as any,
165+
);
166+
await collectEvents(
167+
agent["run"]({
168+
...baseInput,
169+
forwardedProps: {
170+
copilotkitIntelligence: {
171+
// userId + apiKey but no mcpUrl — should not attach.
172+
userId: "jordan",
173+
apiKey: "cpk-proj_xx",
174+
},
175+
},
176+
}),
177+
);
178+
179+
expect(recorder.records.length).toBe(0);
180+
} finally {
181+
recorder.restore();
182+
}
183+
});
184+
185+
it("does NOT attach when the user has already configured a server pointing at the same URL (explicit opt-in wins)", async () => {
186+
const { url, server } = await startMcpMock();
187+
llm = server;
188+
const mcpUrl = `${url}/mcp`;
189+
190+
let userFetchCalls = 0;
191+
const agent = new BasicAgent({
192+
model: "openai/gpt-4o",
193+
mcpServers: [
194+
{
195+
type: "http",
196+
url: mcpUrl,
197+
options: {
198+
fetch: async (input, init) => {
199+
userFetchCalls++;
200+
const h = new Headers(init?.headers ?? {});
201+
h.set("Authorization", "Bearer user-supplied");
202+
h.set("X-Cpki-User-Id", "explicit-user");
203+
return globalThis.fetch(input, { ...init, headers: h });
204+
},
205+
},
206+
},
207+
],
208+
});
209+
210+
const recorder = spyOnFetch(mcpUrl);
211+
try {
212+
vi.mocked(streamText).mockReturnValue(
213+
mockStreamTextResponse([finish()]) as any,
214+
);
215+
await collectEvents(
216+
agent["run"]({
217+
...baseInput,
218+
forwardedProps: {
219+
copilotkitIntelligence: {
220+
userId: "from-runtime",
221+
apiKey: "cpk-proj_runtime",
222+
mcpUrl,
223+
},
224+
},
225+
}),
226+
);
227+
228+
expect(recorder.records.length).toBeGreaterThan(0);
229+
// Only the user's fetch wrapper hit the wire — auto-attach skipped.
230+
for (const headers of recorder.records) {
231+
expect(headers["authorization"]).toBe("Bearer user-supplied");
232+
expect(headers["x-cpki-user-id"]).toBe("explicit-user");
233+
}
234+
expect(userFetchCalls).toBeGreaterThan(0);
235+
} finally {
236+
recorder.restore();
237+
}
238+
});
239+
});

0 commit comments

Comments
 (0)