Skip to content

Commit 6a9410f

Browse files
Swap out custom built MCP client for SDK MCP Client
1 parent 750a764 commit 6a9410f

File tree

6 files changed

+393
-451
lines changed

6 files changed

+393
-451
lines changed

.changeset/add-webmcp-adapter.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"agents": minor
3+
---
4+
5+
Add experimental WebMCP adapter (`agents/experimental/webmcp`) that bridges MCP server tools to Chrome's native `navigator.modelContext` API, enabling browser-native AI agents to discover and call tools registered on a Cloudflare McpAgent.

packages/agents/package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,11 @@
162162
"import": "./dist/experimental/memory/utils/index.js",
163163
"require": "./dist/experimental/memory/utils/index.js"
164164
},
165+
"./experimental/webmcp": {
166+
"types": "./dist/experimental/webmcp.d.ts",
167+
"import": "./dist/experimental/webmcp.js",
168+
"require": "./dist/experimental/webmcp.js"
169+
},
165170
"./x402": {
166171
"types": "./dist/mcp/x402.d.ts",
167172
"import": "./dist/mcp/x402.js",
@@ -220,6 +225,7 @@
220225
"test:cli": "vitest --project cli",
221226
"test:react": "vitest --project react",
222227
"test:workers": "vitest --project workers",
228+
"test:webmcp": "vitest --project webmcp",
223229
"test:x402": "vitest --project x402"
224230
}
225231
}

packages/agents/scripts/build.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ async function main() {
1818
"src/codemode/ai.ts",
1919
"src/experimental/forever.ts",
2020
"src/experimental/memory/session/index.ts",
21-
"src/experimental/memory/utils/index.ts"
21+
"src/experimental/memory/utils/index.ts",
22+
"src/experimental/webmcp.ts"
2223
],
2324
deps: {
2425
skipNodeModulesBundle: true,

packages/agents/src/experimental/webmcp.ts

Lines changed: 86 additions & 200 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@ declare global {
5959
}
6060
}
6161

62-
// ── MCP Streamable HTTP client (minimal, browser-side) ───────────────
62+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
63+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
6364

6465
interface McpTool {
6566
name: string;
@@ -68,242 +69,118 @@ interface McpTool {
6869
annotations?: { readOnlyHint?: boolean };
6970
}
7071

71-
interface McpToolsListResult {
72-
tools: McpTool[];
73-
}
74-
7572
interface McpToolCallResult {
7673
content: Array<{ type: string; text?: string; data?: string }>;
7774
isError?: boolean;
7875
}
7976

80-
interface JsonRpcRequest {
81-
jsonrpc: "2.0";
82-
id?: string | number;
83-
method: string;
84-
params?: Record<string, unknown>;
85-
}
86-
87-
interface JsonRpcResponse {
88-
jsonrpc: "2.0";
89-
id?: string | number | null;
90-
result?: unknown;
91-
error?: { code: number; message: string };
92-
}
93-
94-
/** Minimal MCP Streamable HTTP client for browser use. */
9577
class McpHttpClient {
96-
private _url: string;
97-
private _sessionId: string | null = null;
98-
private _nextId = 1;
99-
private _abortController: AbortController | null = null;
100-
private _headers: Record<string, string>;
101-
private _getHeaders?: () =>
102-
| Promise<Record<string, string>>
103-
| Record<string, string>;
78+
private _client: Client;
79+
private _transport: StreamableHTTPClientTransport;
80+
private _onToolsChanged?: () => void;
10481

10582
constructor(
10683
url: string,
10784
headers?: Record<string, string>,
10885
getHeaders?: () => Promise<Record<string, string>> | Record<string, string>
10986
) {
110-
// Resolve relative URLs against current origin
111-
this._url = new URL(url, globalThis.location?.origin).href;
112-
this._headers = headers ?? {};
113-
this._getHeaders = getHeaders;
114-
}
87+
const resolvedUrl = new URL(url, globalThis.location?.origin);
11588

116-
/** Send a JSON-RPC request and parse the SSE response. */
117-
private async _send(
118-
method: string,
119-
params?: Record<string, unknown>,
120-
id?: number
121-
): Promise<JsonRpcResponse | null> {
122-
const body: JsonRpcRequest = {
123-
jsonrpc: "2.0",
124-
method,
125-
...(id != null ? { id } : {}),
126-
...(params ? { params } : {})
89+
const transportOptions: ConstructorParameters<
90+
typeof StreamableHTTPClientTransport
91+
>[1] = {
92+
requestInit: { headers: headers ?? {} }
12793
};
12894

129-
const dynamic = this._getHeaders ? await this._getHeaders() : {};
130-
const headers: Record<string, string> = {
131-
...this._headers,
132-
...dynamic,
133-
"Content-Type": "application/json",
134-
Accept: "application/json, text/event-stream"
135-
};
136-
137-
if (this._sessionId) {
138-
headers["mcp-session-id"] = this._sessionId;
139-
}
140-
141-
const res = await fetch(this._url, {
142-
method: "POST",
143-
headers,
144-
body: JSON.stringify(body)
145-
});
146-
147-
// Capture session ID from response headers
148-
const sid = res.headers.get("mcp-session-id");
149-
if (sid) {
150-
this._sessionId = sid;
151-
}
152-
153-
// Notifications (no id)
154-
if (id == null) {
155-
return null;
156-
}
157-
158-
const contentType = res.headers.get("content-type") ?? "";
159-
160-
// Direct JSON response
161-
if (contentType.includes("application/json")) {
162-
return (await res.json()) as JsonRpcResponse;
163-
}
164-
165-
// SSE response — parse the first "message" event
166-
if (contentType.includes("text/event-stream")) {
167-
return this._parseSSE(res);
95+
if (getHeaders) {
96+
transportOptions.fetch = async (input, init) => {
97+
const dynamic = await getHeaders();
98+
return globalThis.fetch(input, {
99+
...init,
100+
headers: {
101+
...(init?.headers as Record<string, string>),
102+
...dynamic
103+
}
104+
});
105+
};
168106
}
169107

170-
throw new Error(`Unexpected content-type from MCP server: ${contentType}`);
171-
}
172-
173-
/** Parse a Server-Sent Events response and return the first message. */
174-
private async _parseSSE(res: Response): Promise<JsonRpcResponse> {
175-
const text = await res.text();
176-
const lines = text.split("\n");
177-
for (const line of lines) {
178-
if (line.startsWith("data: ")) {
179-
const data = line.slice(6).trim();
180-
if (data) {
181-
return JSON.parse(data) as JsonRpcResponse;
182-
}
183-
}
184-
}
185-
throw new Error("No data event found in SSE response");
186-
}
108+
this._transport = new StreamableHTTPClientTransport(
109+
resolvedUrl,
110+
transportOptions
111+
);
187112

188-
/** Initialize the MCP session. */
189-
async initialize(): Promise<void> {
190-
const id = this._nextId++;
191-
const res = await this._send(
192-
"initialize",
113+
this._client = new Client(
114+
{ name: "webmcp-adapter", version: "0.1.0" },
193115
{
194-
protocolVersion: "2024-11-05",
195116
capabilities: {},
196-
clientInfo: {
197-
name: "webmcp-adapter",
198-
version: "0.1.0"
117+
listChanged: {
118+
tools: {
119+
onChanged: () => {
120+
this._onToolsChanged?.();
121+
}
122+
}
199123
}
200-
},
201-
id
124+
}
202125
);
126+
}
203127

204-
if (res?.error) {
205-
throw new Error(`MCP initialize failed: ${res.error.message}`);
206-
}
207-
208-
// Send initialized notification
209-
await this._send("notifications/initialized", {});
128+
async initialize(): Promise<void> {
129+
await this._client.connect(this._transport);
210130
}
211131

212-
/** List all tools from the MCP server. */
213132
async listTools(): Promise<McpTool[]> {
214-
const id = this._nextId++;
215-
const res = await this._send("tools/list", {}, id);
216-
217-
if (res?.error) {
218-
throw new Error(`MCP tools/list failed: ${res.error.message}`);
219-
}
220-
221-
const result = res?.result as McpToolsListResult | undefined;
222-
return result?.tools ?? [];
133+
const allTools: McpTool[] = [];
134+
let cursor: string | undefined;
135+
do {
136+
const result = await this._client.listTools(
137+
cursor ? { cursor } : undefined
138+
);
139+
for (const t of result.tools) {
140+
allTools.push({
141+
name: t.name,
142+
description: t.description,
143+
inputSchema: t.inputSchema as Record<string, unknown> | undefined,
144+
annotations: t.annotations
145+
? { readOnlyHint: t.annotations.readOnlyHint }
146+
: undefined
147+
});
148+
}
149+
cursor = result.nextCursor;
150+
} while (cursor);
151+
return allTools;
223152
}
224153

225-
/** Call a tool on the MCP server. */
226154
async callTool(
227155
name: string,
228156
args: Record<string, unknown>
229157
): Promise<McpToolCallResult> {
230-
const id = this._nextId++;
231-
const res = await this._send("tools/call", { name, arguments: args }, id);
232-
233-
if (res?.error) {
234-
throw new Error(`MCP tools/call failed: ${res.error.message}`);
158+
const result = await this._client.callTool({ name, arguments: args });
159+
if ("content" in result) {
160+
return {
161+
content: (
162+
result.content as Array<{
163+
type: string;
164+
text?: string;
165+
data?: string;
166+
}>
167+
).map((c) => ({
168+
type: c.type,
169+
text: "text" in c ? (c.text as string) : undefined,
170+
data: "data" in c ? (c.data as string) : undefined
171+
})),
172+
isError: "isError" in result ? (result.isError as boolean) : false
173+
};
235174
}
236-
237-
return (res?.result as McpToolCallResult) ?? { content: [] };
175+
return { content: [], isError: false };
238176
}
239177

240-
/** Open an SSE stream for server notifications (tools/list_changed). */
241178
listenForChanges(onToolsChanged: () => void): void {
242-
if (!this._sessionId) return;
243-
244-
this._abortController = new AbortController();
245-
246-
Promise.resolve(this._getHeaders ? this._getHeaders() : {})
247-
.then((dynamic) => {
248-
const headers: Record<string, string> = {
249-
...this._headers,
250-
...dynamic,
251-
Accept: "text/event-stream"
252-
};
253-
if (this._sessionId) {
254-
headers["mcp-session-id"] = this._sessionId;
255-
}
256-
return fetch(this._url, {
257-
method: "GET",
258-
headers,
259-
signal: this._abortController?.signal
260-
});
261-
})
262-
.then(async (res) => {
263-
if (!res.body) return;
264-
const reader = res.body.getReader();
265-
const decoder = new TextDecoder();
266-
let buffer = "";
267-
268-
while (true) {
269-
const { done, value } = await reader.read();
270-
if (done) break;
271-
272-
buffer += decoder.decode(value, { stream: true });
273-
const lines = buffer.split("\n");
274-
buffer = lines.pop() ?? "";
275-
276-
for (const line of lines) {
277-
if (line.startsWith("data: ")) {
278-
const data = line.slice(6).trim();
279-
if (!data) continue;
280-
try {
281-
const msg = JSON.parse(data) as JsonRpcResponse;
282-
if (
283-
"method" in msg &&
284-
(msg as unknown as { method: string }).method ===
285-
"notifications/tools/list_changed"
286-
) {
287-
onToolsChanged();
288-
}
289-
} catch {
290-
// Ignore non-JSON SSE data
291-
}
292-
}
293-
}
294-
}
295-
})
296-
.catch((err: unknown) => {
297-
// AbortError is expected on dispose
298-
if (err instanceof Error && err.name === "AbortError") return;
299-
console.warn("[webmcp-adapter] SSE listener error:", err);
300-
});
179+
this._onToolsChanged = onToolsChanged;
301180
}
302181

303-
/** Close the SSE listener. */
304182
close(): void {
305-
this._abortController?.abort();
306-
this._abortController = null;
183+
this._client.close().catch(() => {});
307184
}
308185
}
309186

@@ -424,8 +301,17 @@ export async function registerWebMcp(
424301
throw new Error(errorText || "Tool execution failed");
425302
}
426303

427-
// Return the text content as the result
428-
return result.content.map((c) => c.text ?? "").join("\n");
304+
const parts: string[] = [];
305+
for (const c of result.content) {
306+
if (c.type === "text" && c.text) {
307+
parts.push(c.text);
308+
} else if (c.type === "image" && c.data) {
309+
parts.push(`data:image;base64,${c.data}`);
310+
} else if (c.data) {
311+
parts.push(c.data);
312+
}
313+
}
314+
return parts.join("\n");
429315
}
430316
};
431317

0 commit comments

Comments
 (0)