Skip to content

Commit 9fa93c2

Browse files
authored
Test the default MCP User-Agent (#2255)
1 parent 6e768da commit 9fa93c2

2 files changed

Lines changed: 178 additions & 0 deletions

File tree

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import { describe, expect, it } from "@effect/vitest";
2+
import { Effect, Layer } from "effect";
3+
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
4+
import type { OAuthClientProvider } from "@modelcontextprotocol/client";
5+
6+
import { createMcpConnector } from "./connection";
7+
import { makeEchoMcpServer, serveMcpServer } from "../testing";
8+
9+
const endpoint = "https://mcp.example/mcp";
10+
11+
/** Records each request's User-Agent and answers 405, so `auto` exercises
12+
* both the Streamable HTTP attempt and the SSE fallback. */
13+
const recordingClientLayer = (userAgents: Array<string | undefined>) =>
14+
Layer.succeed(HttpClient.HttpClient)(
15+
HttpClient.make((request: HttpClientRequest.HttpClientRequest) => {
16+
userAgents.push(request.headers["user-agent"]);
17+
return Effect.succeed(
18+
HttpClientResponse.fromWeb(request, new Response("unsupported", { status: 405 })),
19+
);
20+
}),
21+
);
22+
23+
const unauthorizedProvider: OAuthClientProvider = {
24+
get redirectUrl() {
25+
return "http://localhost/oauth/callback";
26+
},
27+
get clientMetadata() {
28+
return {
29+
redirect_uris: ["http://localhost/oauth/callback"],
30+
grant_types: ["authorization_code"] as string[],
31+
response_types: ["code"] as string[],
32+
token_endpoint_auth_method: "none" as const,
33+
client_name: "Executor",
34+
};
35+
},
36+
clientInformation: () => ({ client_id: "test-client" }),
37+
saveClientInformation: () => undefined,
38+
tokens: () => ({ access_token: "rejected-token", token_type: "Bearer" }),
39+
saveTokens: () => undefined,
40+
redirectToAuthorization: () => undefined,
41+
saveCodeVerifier: () => undefined,
42+
codeVerifier: () => "unused",
43+
};
44+
45+
describe("MCP remote User-Agent", () => {
46+
it.effect("sends a default User-Agent on both transports through the HTTP client layer", () =>
47+
Effect.gen(function* () {
48+
const userAgents: Array<string | undefined> = [];
49+
50+
yield* createMcpConnector({
51+
transport: "remote",
52+
endpoint,
53+
remoteTransport: "auto",
54+
httpClientLayer: recordingClientLayer(userAgents),
55+
}).pipe(Effect.flip);
56+
57+
expect(userAgents.length).toBeGreaterThanOrEqual(2);
58+
expect(new Set(userAgents)).toEqual(new Set(["executor"]));
59+
}),
60+
);
61+
62+
it.effect("keeps a configured User-Agent through the HTTP client layer", () =>
63+
Effect.gen(function* () {
64+
const userAgents: Array<string | undefined> = [];
65+
66+
yield* createMcpConnector({
67+
transport: "remote",
68+
endpoint,
69+
remoteTransport: "auto",
70+
headers: { "user-agent": "custom-agent/1.0" },
71+
httpClientLayer: recordingClientLayer(userAgents),
72+
}).pipe(Effect.flip);
73+
74+
expect(userAgents.length).toBeGreaterThanOrEqual(2);
75+
expect(new Set(userAgents)).toEqual(new Set(["custom-agent/1.0"]));
76+
}),
77+
);
78+
79+
it.effect("sends a default User-Agent over Streamable HTTP with global fetch", () =>
80+
Effect.scoped(
81+
Effect.gen(function* () {
82+
const server = yield* serveMcpServer(() => makeEchoMcpServer());
83+
84+
const connection = yield* createMcpConnector({
85+
transport: "remote",
86+
endpoint: server.endpoint,
87+
remoteTransport: "streamable-http",
88+
});
89+
yield* Effect.promise(() => connection.client.listTools());
90+
yield* Effect.promise(() => connection.close());
91+
92+
const requests = yield* server.requests;
93+
expect(requests.length).toBeGreaterThan(0);
94+
expect(new Set(requests.map((request) => request.userAgent))).toEqual(
95+
new Set(["executor"]),
96+
);
97+
}),
98+
),
99+
);
100+
101+
it.effect("keeps a configured User-Agent over Streamable HTTP with global fetch", () =>
102+
Effect.scoped(
103+
Effect.gen(function* () {
104+
const server = yield* serveMcpServer(() => makeEchoMcpServer());
105+
106+
const connection = yield* createMcpConnector({
107+
transport: "remote",
108+
endpoint: server.endpoint,
109+
remoteTransport: "streamable-http",
110+
headers: { "User-Agent": "custom-agent/1.0" },
111+
});
112+
yield* Effect.promise(() => connection.close());
113+
114+
const requests = yield* server.requests;
115+
expect(requests.length).toBeGreaterThan(0);
116+
expect(new Set(requests.map((request) => request.userAgent))).toEqual(
117+
new Set(["custom-agent/1.0"]),
118+
);
119+
}),
120+
),
121+
);
122+
123+
it.effect("sends a default User-Agent on the SSE stream with global fetch", () =>
124+
Effect.scoped(
125+
Effect.gen(function* () {
126+
// The test server speaks Streamable HTTP only, so the SSE dial fails;
127+
// the request it made is what this asserts on.
128+
const server = yield* serveMcpServer(() => makeEchoMcpServer());
129+
130+
yield* createMcpConnector({
131+
transport: "remote",
132+
endpoint: server.endpoint,
133+
remoteTransport: "sse",
134+
}).pipe(Effect.flip);
135+
136+
const requests = yield* server.requests;
137+
expect(requests.map((request) => request.method)).toContain("GET");
138+
expect(new Set(requests.map((request) => request.userAgent))).toEqual(
139+
new Set(["executor"]),
140+
);
141+
}),
142+
),
143+
);
144+
145+
it.effect("sends a default User-Agent on OAuth resource metadata discovery", () =>
146+
Effect.scoped(
147+
Effect.gen(function* () {
148+
const server = yield* serveMcpServer(() => makeEchoMcpServer(), {
149+
auth: {
150+
validateAuthorization: () => Effect.succeed(false),
151+
// Unreachable on purpose: discovery stops after the resource
152+
// metadata request this test inspects.
153+
authorizationServerUrls: ["http://127.0.0.1:1"],
154+
},
155+
});
156+
157+
yield* createMcpConnector({
158+
transport: "remote",
159+
endpoint: server.endpoint,
160+
remoteTransport: "streamable-http",
161+
authProvider: unauthorizedProvider,
162+
}).pipe(Effect.flip);
163+
164+
const requests = yield* server.requests;
165+
const discovery = requests.filter((request) =>
166+
request.url.startsWith("/.well-known/oauth-protected-resource"),
167+
);
168+
expect(discovery.length).toBeGreaterThan(0);
169+
expect(new Set(requests.map((request) => request.userAgent))).toEqual(
170+
new Set(["executor"]),
171+
);
172+
}),
173+
),
174+
);
175+
});

‎packages/plugins/mcp/src/testing/server.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export type McpTestRequest = {
3131
readonly method: string;
3232
readonly url: string;
3333
readonly authorization: string | undefined;
34+
readonly userAgent: string | undefined;
3435
readonly sessionId: string | undefined;
3536
};
3637

@@ -137,6 +138,7 @@ export const serveMcpServer = (factory: () => McpServer, options: McpTestServerO
137138
const authorization = Array.isArray(request.headers.authorization)
138139
? request.headers.authorization[0]
139140
: request.headers.authorization;
141+
const userAgent = request.headers["user-agent"];
140142
const origin = request.headers.host
141143
? `http://${request.headers.host}`
142144
: "http://127.0.0.1";
@@ -147,6 +149,7 @@ export const serveMcpServer = (factory: () => McpServer, options: McpTestServerO
147149
method: request.method ?? "GET",
148150
url: requestUrl,
149151
authorization,
152+
userAgent,
150153
sessionId,
151154
},
152155
]);

0 commit comments

Comments
 (0)