@decocms/runtime 1.0.0-alpha.23 → 1.0.0-alpha.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,24 +1,22 @@
1
1
  {
2
2
  "name": "@decocms/runtime",
3
- "version": "1.0.0-alpha.23",
3
+ "version": "1.0.0-alpha.25",
4
4
  "type": "module",
5
5
  "dependencies": {
6
6
  "@cloudflare/workers-types": "^4.20250617.0",
7
7
  "@deco/mcp": "npm:@jsr/deco__mcp@0.5.5",
8
- "@decocms/bindings": "1.0.1-alpha.12",
8
+ "@decocms/bindings": "1.0.1-alpha.16",
9
9
  "@modelcontextprotocol/sdk": "1.20.2",
10
10
  "@ai-sdk/provider": "^2.0.0",
11
11
  "hono": "^4.10.7",
12
12
  "jose": "^6.0.11",
13
13
  "zod": "^3.25.76",
14
- "zod-from-json-schema": "^0.0.5",
15
14
  "zod-to-json-schema": "3.25.0"
16
15
  },
17
16
  "exports": {
18
17
  ".": "./src/index.ts",
19
18
  "./proxy": "./src/proxy.ts",
20
19
  "./client": "./src/client.ts",
21
- "./mcp-client": "./src/mcp-client.ts",
22
20
  "./bindings": "./src/bindings/index.ts",
23
21
  "./asset-server": "./src/asset-server/index.ts",
24
22
  "./tools": "./src/tools.ts"
@@ -33,4 +31,4 @@
33
31
  "publishConfig": {
34
32
  "access": "public"
35
33
  }
36
- }
34
+ }
package/src/index.ts CHANGED
@@ -83,6 +83,7 @@ export interface RequestContext<TSchema extends z.ZodTypeAny = any> {
83
83
  state: z.infer<TSchema>;
84
84
  token: string;
85
85
  meshUrl: string;
86
+ authorization?: string | null;
86
87
  ensureAuthenticated: (options?: {
87
88
  workspaceHint?: string;
88
89
  }) => User | undefined;
@@ -159,14 +160,19 @@ export const withBindings = <TEnv>({
159
160
  tokenOrContext,
160
161
  url,
161
162
  bindings: inlineBindings,
163
+ authToken,
162
164
  }: {
163
165
  env: TEnv;
164
166
  server: MCPServer<TEnv, any>;
167
+ // token is x-mesh-token
165
168
  tokenOrContext?: string | RequestContext;
169
+ // authToken is the authorization header
170
+ authToken?: string | null;
166
171
  url?: string;
167
172
  bindings?: Binding[];
168
173
  }): TEnv => {
169
174
  const env = _env as DefaultEnv<any>;
175
+ const authorization = authToken ? authToken.split(" ")[1] : undefined;
170
176
 
171
177
  let context;
172
178
  if (typeof tokenOrContext === "string") {
@@ -180,6 +186,7 @@ export const withBindings = <TEnv>({
180
186
  }) ?? {};
181
187
 
182
188
  context = {
189
+ authorization,
183
190
  state: decoded.state ?? metadata.state,
184
191
  token: tokenOrContext,
185
192
  meshUrl: (decoded.meshUrl as string) ?? metadata.meshUrl,
@@ -197,6 +204,7 @@ export const withBindings = <TEnv>({
197
204
  connectionId?: string;
198
205
  }) ?? {};
199
206
  const appName = decoded.appName as string | undefined;
207
+ context.authorization ??= authorization;
200
208
  context.callerApp = appName;
201
209
  context.connectionId ??=
202
210
  (decoded.connectionId as string) ?? metadata.connectionId;
@@ -204,6 +212,7 @@ export const withBindings = <TEnv>({
204
212
  } else {
205
213
  context = {
206
214
  state: {},
215
+ authorization,
207
216
  token: undefined,
208
217
  meshUrl: undefined,
209
218
  connectionId: undefined,
@@ -254,13 +263,31 @@ export const withRuntime = <TEnv, TSchema extends z.ZodTypeAny = never>(
254
263
  return oauthHandlers.handleProtectedResourceMetadata(req);
255
264
  }
256
265
 
266
+ // Authorization server metadata (RFC8414)
267
+ if (url.pathname === "/.well-known/oauth-authorization-server") {
268
+ return oauthHandlers.handleAuthorizationServerMetadata(req);
269
+ }
270
+
271
+ // Authorization endpoint - redirects to external OAuth provider
272
+ if (url.pathname === "/authorize") {
273
+ return oauthHandlers.handleAuthorize(req);
274
+ }
275
+
257
276
  // OAuth callback - receives code from external OAuth provider
258
277
  if (url.pathname === "/oauth/callback") {
259
278
  return oauthHandlers.handleOAuthCallback(req);
260
279
  }
261
280
 
281
+ // Token endpoint - exchanges code for tokens
282
+ if (url.pathname === "/token" && req.method === "POST") {
283
+ return oauthHandlers.handleToken(req);
284
+ }
285
+
262
286
  // Dynamic client registration (RFC7591)
263
- if (url.pathname === "/mcp/register" && req.method === "POST") {
287
+ if (
288
+ (url.pathname === "/register" || url.pathname === "/mcp/register") &&
289
+ req.method === "POST"
290
+ ) {
264
291
  return oauthHandlers.handleClientRegistration(req);
265
292
  }
266
293
  }
@@ -312,6 +339,7 @@ export const withRuntime = <TEnv, TSchema extends z.ZodTypeAny = never>(
312
339
  }
313
340
 
314
341
  const bindings = withBindings({
342
+ authToken: req.headers.get("authorization") ?? null,
315
343
  env,
316
344
  server,
317
345
  bindings: userFns.bindings,
package/src/oauth.ts CHANGED
@@ -29,14 +29,52 @@ function isValidRedirectUri(uri: string): boolean {
29
29
  }
30
30
  }
31
31
 
32
+ /**
33
+ * Encode data as base64url JSON
34
+ */
35
+ function encodeState<T>(data: T): string {
36
+ return btoa(JSON.stringify(data))
37
+ .replace(/\+/g, "-")
38
+ .replace(/\//g, "_")
39
+ .replace(/=+$/, "");
40
+ }
41
+
42
+ /**
43
+ * Decode base64url JSON data
44
+ */
45
+ function decodeState<T>(encoded: string): T | null {
46
+ try {
47
+ const base64 = encoded.replace(/-/g, "+").replace(/_/g, "/");
48
+ return JSON.parse(atob(base64)) as T;
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ interface PendingAuthState {
55
+ redirectUri: string;
56
+ clientState?: string;
57
+ codeChallenge?: string;
58
+ codeChallengeMethod?: string;
59
+ }
60
+
61
+ interface CodePayload {
62
+ accessToken: string;
63
+ tokenType: string;
64
+ codeChallenge?: string;
65
+ codeChallengeMethod?: string;
66
+ }
67
+
32
68
  /**
33
69
  * Create OAuth endpoint handlers for MCP servers
70
+ * The MCP server acts as an OAuth Authorization Server proxy
71
+ * Stateless implementation - no persistence required
34
72
  * Per MCP Authorization spec: https://modelcontextprotocol.io/specification/draft/basic/authorization
35
73
  */
36
74
  export function createOAuthHandlers(oauth: OAuthConfig) {
37
75
  /**
38
76
  * Build OAuth 2.0 Protected Resource Metadata (RFC9728)
39
- * Per MCP spec, this MUST point to the external authorization server
77
+ * Points to THIS server as the authorization server
40
78
  */
41
79
  const handleProtectedResourceMetadata = (req: Request): Response => {
42
80
  const url = new URL(req.url);
@@ -44,7 +82,8 @@ export function createOAuthHandlers(oauth: OAuthConfig) {
44
82
 
45
83
  return Response.json({
46
84
  resource: resourceUrl,
47
- authorization_servers: [oauth.authorizationServer],
85
+ // Point to ourselves - we are the authorization server proxy
86
+ authorization_servers: [url.origin],
48
87
  scopes_supported: ["*"],
49
88
  bearer_methods_supported: ["header"],
50
89
  resource_signing_alg_values_supported: ["RS256", "none"],
@@ -52,55 +91,273 @@ export function createOAuthHandlers(oauth: OAuthConfig) {
52
91
  };
53
92
 
54
93
  /**
55
- * Handle OAuth callback - receives code from external OAuth provider
94
+ * Build OAuth 2.0 Authorization Server Metadata (RFC8414)
95
+ * Exposes our endpoints for authorization, token exchange, and registration
96
+ */
97
+ const handleAuthorizationServerMetadata = (req: Request): Response => {
98
+ const url = new URL(req.url);
99
+ const baseUrl = url.origin;
100
+
101
+ return Response.json({
102
+ issuer: baseUrl,
103
+ authorization_endpoint: `${baseUrl}/authorize`,
104
+ token_endpoint: `${baseUrl}/token`,
105
+ registration_endpoint: `${baseUrl}/register`,
106
+ scopes_supported: ["*"],
107
+ response_types_supported: ["code"],
108
+ response_modes_supported: ["query"],
109
+ grant_types_supported: ["authorization_code", "refresh_token"],
110
+ token_endpoint_auth_methods_supported: ["none", "client_secret_post"],
111
+ code_challenge_methods_supported: ["S256", "plain"],
112
+ });
113
+ };
114
+
115
+ /**
116
+ * Handle authorization request - redirects to external OAuth provider
117
+ * Stateless: encodes all needed info in the state parameter
118
+ */
119
+ const handleAuthorize = (req: Request): Response => {
120
+ const url = new URL(req.url);
121
+ const redirectUri = url.searchParams.get("redirect_uri");
122
+ const responseType = url.searchParams.get("response_type");
123
+ const clientState = url.searchParams.get("state");
124
+ const codeChallenge = url.searchParams.get("code_challenge");
125
+ const codeChallengeMethod = url.searchParams.get("code_challenge_method");
126
+
127
+ // Validate required params
128
+ if (!redirectUri) {
129
+ return Response.json(
130
+ {
131
+ error: "invalid_request",
132
+ error_description: "redirect_uri required",
133
+ },
134
+ { status: 400 },
135
+ );
136
+ }
137
+
138
+ if (responseType !== "code") {
139
+ return Response.json(
140
+ {
141
+ error: "unsupported_response_type",
142
+ error_description: "Only 'code' is supported",
143
+ },
144
+ { status: 400 },
145
+ );
146
+ }
147
+
148
+ // Encode pending auth state
149
+ const pendingState: PendingAuthState = {
150
+ redirectUri,
151
+ clientState: clientState ?? undefined,
152
+ codeChallenge: codeChallenge ?? undefined,
153
+ codeChallengeMethod: codeChallengeMethod ?? undefined,
154
+ };
155
+ const encodedState = encodeState(pendingState);
156
+
157
+ // Build callback URL pointing to our internal callback
158
+ const callbackUrl = new URL(`${url.origin}/oauth/callback`);
159
+ callbackUrl.searchParams.set("state", encodedState);
160
+
161
+ // Get the external authorization URL from the config
162
+ const externalAuthUrl = oauth.authorizationUrl(callbackUrl.toString());
163
+
164
+ // Redirect to external OAuth provider
165
+ return Response.redirect(externalAuthUrl, 302);
166
+ };
167
+
168
+ /**
169
+ * Handle OAuth callback from external provider
170
+ * Stateless: decodes state to get redirect info, encodes token in code
56
171
  */
57
172
  const handleOAuthCallback = async (req: Request): Promise<Response> => {
58
173
  const url = new URL(req.url);
59
174
  const code = url.searchParams.get("code");
60
- const codeVerifier = url.searchParams.get("code_verifier") ?? undefined;
61
- const codeChallengeMethod = url.searchParams.get("code_challenge_method") as
62
- | "S256"
63
- | "plain"
64
- | undefined;
175
+ const encodedState = url.searchParams.get("state");
176
+ const error = url.searchParams.get("error");
177
+
178
+ // Decode state
179
+ const pending = encodedState
180
+ ? decodeState<PendingAuthState>(encodedState)
181
+ : null;
65
182
 
66
- if (!code) {
183
+ if (error) {
184
+ const errorDescription =
185
+ url.searchParams.get("error_description") ?? "Authorization failed";
186
+ if (pending?.redirectUri) {
187
+ const redirectUrl = new URL(pending.redirectUri);
188
+ redirectUrl.searchParams.set("error", error);
189
+ redirectUrl.searchParams.set("error_description", errorDescription);
190
+ if (pending.clientState)
191
+ redirectUrl.searchParams.set("state", pending.clientState);
192
+ return Response.redirect(redirectUrl.toString(), 302);
193
+ }
194
+ return Response.json(
195
+ { error, error_description: errorDescription },
196
+ { status: 400 },
197
+ );
198
+ }
199
+
200
+ if (!code || !pending) {
67
201
  return Response.json(
68
202
  {
69
203
  error: "invalid_request",
70
- error_description: "Missing code parameter",
204
+ error_description: "Missing code or state",
71
205
  },
72
206
  { status: 400 },
73
207
  );
74
208
  }
75
209
 
76
210
  try {
77
- const oauthParams: OAuthParams = {
78
- code,
79
- code_verifier: codeVerifier,
80
- code_challenge_method: codeChallengeMethod,
81
- };
211
+ // Exchange code with external provider
212
+ const oauthParams: OAuthParams = { code };
82
213
  const tokenResponse = await oauth.exchangeCode(oauthParams);
83
214
 
84
- return Response.json(tokenResponse, {
85
- headers: {
86
- "Cache-Control": "no-store",
87
- Pragma: "no-cache",
215
+ // Encode the token in our own code (stateless)
216
+ const codePayload: CodePayload = {
217
+ accessToken: tokenResponse.access_token,
218
+ tokenType: tokenResponse.token_type,
219
+ codeChallenge: pending.codeChallenge,
220
+ codeChallengeMethod: pending.codeChallengeMethod,
221
+ };
222
+ const ourCode = encodeState(codePayload);
223
+
224
+ // Redirect back to client with our code
225
+ const redirectUrl = new URL(pending.redirectUri);
226
+ redirectUrl.searchParams.set("code", ourCode);
227
+ if (pending.clientState) {
228
+ redirectUrl.searchParams.set("state", pending.clientState);
229
+ }
230
+
231
+ return Response.redirect(redirectUrl.toString(), 302);
232
+ } catch (err) {
233
+ console.error("OAuth callback error:", err);
234
+
235
+ // Redirect back to client with error
236
+ const redirectUrl = new URL(pending.redirectUri);
237
+ redirectUrl.searchParams.set("error", "server_error");
238
+ redirectUrl.searchParams.set(
239
+ "error_description",
240
+ "Failed to exchange authorization code",
241
+ );
242
+ if (pending.clientState)
243
+ redirectUrl.searchParams.set("state", pending.clientState);
244
+
245
+ return Response.redirect(redirectUrl.toString(), 302);
246
+ }
247
+ };
248
+
249
+ /**
250
+ * Handle token exchange - decodes our code to get the actual token
251
+ * Stateless: token is encoded in the code
252
+ */
253
+ const handleToken = async (req: Request): Promise<Response> => {
254
+ try {
255
+ const contentType = req.headers.get("content-type") ?? "";
256
+ let body: Record<string, string>;
257
+
258
+ if (contentType.includes("application/x-www-form-urlencoded")) {
259
+ const formData = await req.formData();
260
+ body = Object.fromEntries(formData.entries()) as Record<string, string>;
261
+ } else {
262
+ body = await req.json();
263
+ }
264
+
265
+ const { code, code_verifier, grant_type } = body;
266
+
267
+ if (grant_type !== "authorization_code") {
268
+ return Response.json(
269
+ {
270
+ error: "unsupported_grant_type",
271
+ error_description: "Only authorization_code supported",
272
+ },
273
+ { status: 400 },
274
+ );
275
+ }
276
+
277
+ if (!code) {
278
+ return Response.json(
279
+ { error: "invalid_request", error_description: "code is required" },
280
+ { status: 400 },
281
+ );
282
+ }
283
+
284
+ // Decode the code to get the token
285
+ const payload = decodeState<CodePayload>(code);
286
+ if (!payload || !payload.accessToken) {
287
+ return Response.json(
288
+ {
289
+ error: "invalid_grant",
290
+ error_description: "Invalid or expired code",
291
+ },
292
+ { status: 400 },
293
+ );
294
+ }
295
+
296
+ // Verify PKCE if code challenge was provided
297
+ if (payload.codeChallenge) {
298
+ if (!code_verifier) {
299
+ return Response.json(
300
+ {
301
+ error: "invalid_grant",
302
+ error_description: "code_verifier required",
303
+ },
304
+ { status: 400 },
305
+ );
306
+ }
307
+
308
+ // Verify the code verifier
309
+ let computedChallenge: string;
310
+ if (payload.codeChallengeMethod === "S256") {
311
+ const encoder = new TextEncoder();
312
+ const data = encoder.encode(code_verifier);
313
+ const hash = await crypto.subtle.digest("SHA-256", data);
314
+ computedChallenge = btoa(String.fromCharCode(...new Uint8Array(hash)))
315
+ .replace(/\+/g, "-")
316
+ .replace(/\//g, "_")
317
+ .replace(/=+$/, "");
318
+ } else {
319
+ computedChallenge = code_verifier;
320
+ }
321
+
322
+ if (computedChallenge !== payload.codeChallenge) {
323
+ return Response.json(
324
+ {
325
+ error: "invalid_grant",
326
+ error_description: "Invalid code_verifier",
327
+ },
328
+ { status: 400 },
329
+ );
330
+ }
331
+ }
332
+
333
+ // Return the actual token
334
+ return Response.json(
335
+ {
336
+ access_token: payload.accessToken,
337
+ token_type: payload.tokenType,
88
338
  },
89
- });
90
- } catch (error) {
91
- console.error("OAuth code exchange error:", error);
339
+ {
340
+ headers: {
341
+ "Cache-Control": "no-store",
342
+ Pragma: "no-cache",
343
+ },
344
+ },
345
+ );
346
+ } catch (err) {
347
+ console.error("Token exchange error:", err);
92
348
  return Response.json(
93
349
  {
94
- error: "invalid_grant",
95
- error_description: "Failed to exchange authorization code",
350
+ error: "server_error",
351
+ error_description: "Failed to process token request",
96
352
  },
97
- { status: 400 },
353
+ { status: 500 },
98
354
  );
99
355
  }
100
356
  };
101
357
 
102
358
  /**
103
359
  * Handle dynamic client registration (RFC7591)
360
+ * Stateless: just generates a client_id and returns it, no storage needed
104
361
  */
105
362
  const handleClientRegistration = async (req: Request): Promise<Response> => {
106
363
  try {
@@ -111,6 +368,7 @@ export function createOAuthHandlers(oauth: OAuthConfig) {
111
368
  response_types?: string[];
112
369
  token_endpoint_auth_method?: string;
113
370
  scope?: string;
371
+ client_uri?: string;
114
372
  };
115
373
 
116
374
  // Validate redirect URIs
@@ -170,8 +428,8 @@ export function createOAuthHandlers(oauth: OAuthConfig) {
170
428
  Pragma: "no-cache",
171
429
  },
172
430
  });
173
- } catch (error) {
174
- console.error("Client registration error:", error);
431
+ } catch (err) {
432
+ console.error("Client registration error:", err);
175
433
  return Response.json(
176
434
  {
177
435
  error: "invalid_client_metadata",
@@ -221,7 +479,10 @@ export function createOAuthHandlers(oauth: OAuthConfig) {
221
479
 
222
480
  return {
223
481
  handleProtectedResourceMetadata,
482
+ handleAuthorizationServerMetadata,
483
+ handleAuthorize,
224
484
  handleOAuthCallback,
485
+ handleToken,
225
486
  handleClientRegistration,
226
487
  createUnauthorizedResponse,
227
488
  hasAuth,
package/src/proxy.ts CHANGED
@@ -1,157 +1 @@
1
- /* oxlint-disable no-explicit-any */
2
- import { convertJsonSchemaToZod } from "zod-from-json-schema";
3
- import { MCPConnection } from "./connection.ts";
4
- import { createServerClient } from "./mcp-client.ts";
5
- import type { CreateStubAPIOptions } from "./mcp.ts";
6
-
7
- const safeParse = (content: string) => {
8
- try {
9
- return JSON.parse(content as string);
10
- } catch {
11
- return content;
12
- }
13
- };
14
-
15
- const toolsMap = new Map<
16
- string,
17
- Promise<
18
- Array<{
19
- name: string;
20
- inputSchema: any;
21
- outputSchema?: any;
22
- description: string;
23
- }>
24
- >
25
- >();
26
-
27
- /**
28
- * The base fetcher used to fetch the MCP from API.
29
- */
30
- export function createMCPClientProxy<T extends Record<string, unknown>>(
31
- options: CreateStubAPIOptions,
32
- ): T {
33
- const connection: MCPConnection = options.connection;
34
-
35
- return new Proxy<T>({} as T, {
36
- get(_, name) {
37
- if (name === "toJSON") {
38
- return null;
39
- }
40
- if (typeof name !== "string") {
41
- throw new Error("Name must be a string");
42
- }
43
- async function callToolFn(args: unknown) {
44
- const debugId = options?.debugId?.();
45
- const extraHeaders = debugId
46
- ? { "x-trace-debug-id": debugId }
47
- : undefined;
48
-
49
- // Create a connection with the tool name in the URL path for better logging
50
- // Only modify connections that have a URL property (HTTP, SSE, Websocket)
51
- // Use automatic detection based on URL, with optional override
52
-
53
- const { client, callStreamableTool } = await createServerClient(
54
- { connection },
55
- undefined,
56
- extraHeaders,
57
- );
58
-
59
- if (options?.streamable?.[String(name)]) {
60
- return callStreamableTool(String(name), args);
61
- }
62
-
63
- const { structuredContent, isError, content } = await client.callTool(
64
- {
65
- name: String(name),
66
- arguments: args as Record<string, unknown>,
67
- },
68
- undefined,
69
- {
70
- timeout: 3000000,
71
- },
72
- );
73
-
74
- if (isError) {
75
- // @ts-expect-error - content is not typed
76
- const maybeErrorMessage = content?.[0]?.text;
77
- const error =
78
- typeof maybeErrorMessage === "string"
79
- ? safeParse(maybeErrorMessage)
80
- : null;
81
-
82
- const throwableError =
83
- error?.code && typeof options?.getErrorByStatusCode === "function"
84
- ? options.getErrorByStatusCode(
85
- error.code,
86
- error.message,
87
- error.traceId,
88
- )
89
- : null;
90
-
91
- if (throwableError) {
92
- throw throwableError;
93
- }
94
-
95
- throw new Error(
96
- `Tool ${String(name)} returned an error: ${JSON.stringify(
97
- structuredContent ?? content,
98
- )}`,
99
- );
100
- }
101
- return structuredContent;
102
- }
103
-
104
- const listToolsFn = async () => {
105
- const { client } = await createServerClient({ connection });
106
- const { tools } = await client.listTools();
107
-
108
- return tools as {
109
- name: string;
110
- inputSchema: any;
111
- outputSchema?: any;
112
- description: string;
113
- }[];
114
- };
115
-
116
- async function listToolsOnce() {
117
- const conn = connection;
118
- const key = JSON.stringify(conn);
119
-
120
- try {
121
- if (!toolsMap.has(key)) {
122
- toolsMap.set(key, listToolsFn());
123
- }
124
-
125
- return await toolsMap.get(key)!;
126
- } catch (error) {
127
- console.error("Failed to list tools", error);
128
-
129
- toolsMap.delete(key);
130
- return;
131
- }
132
- }
133
- callToolFn.asTool = async () => {
134
- const tools = (await listToolsOnce()) ?? [];
135
- const tool = tools.find((t) => t.name === name);
136
- if (!tool) {
137
- throw new Error(`Tool ${name} not found`);
138
- }
139
-
140
- return {
141
- ...tool,
142
- id: tool.name,
143
- inputSchema: tool.inputSchema
144
- ? convertJsonSchemaToZod(tool.inputSchema)
145
- : undefined,
146
- outputSchema: tool.outputSchema
147
- ? convertJsonSchemaToZod(tool.outputSchema)
148
- : undefined,
149
- execute: (input: any) => {
150
- return callToolFn(input.context);
151
- },
152
- };
153
- };
154
- return callToolFn;
155
- },
156
- });
157
- }
1
+ export * from "@decocms/bindings/client";
@@ -1 +0,0 @@
1
- export { HTTPClientTransport } from "@decocms/bindings/client";
package/src/mcp-client.ts DELETED
@@ -1,139 +0,0 @@
1
- import {
2
- Client as BaseClient,
3
- ClientOptions,
4
- } from "@modelcontextprotocol/sdk/client/index.js";
5
- import {
6
- SSEClientTransport,
7
- SSEClientTransportOptions,
8
- } from "@modelcontextprotocol/sdk/client/sse.js";
9
- import { WebSocketClientTransport } from "@modelcontextprotocol/sdk/client/websocket.js";
10
- import { RequestOptions } from "@modelcontextprotocol/sdk/shared/protocol.js";
11
- import {
12
- Implementation,
13
- ListToolsRequest,
14
- ListToolsResultSchema,
15
- } from "@modelcontextprotocol/sdk/types.js";
16
- import { MCPConnection } from "./connection.ts";
17
- import { HTTPClientTransport } from "./http-client-transport.ts";
18
-
19
- /**
20
- * WARNNING: This is a hack to prevent schema compilation errors.
21
- * More info at: https://github.com/modelcontextprotocol/typescript-sdk/issues/923
22
- *
23
- * Make sure to keep this updated with the right version of the SDK.
24
- * https://github.com/modelcontextprotocol/typescript-sdk/blob/bf817939917277a4c59f2e19e7b44b8dd7ff140c/src/client/index.ts#L480
25
- */
26
- class Client extends BaseClient {
27
- constructor(_clientInfo: Implementation, options?: ClientOptions) {
28
- super(_clientInfo, options);
29
- }
30
-
31
- override async listTools(
32
- params?: ListToolsRequest["params"],
33
- options?: RequestOptions,
34
- ) {
35
- const result = await this.request(
36
- { method: "tools/list", params },
37
- ListToolsResultSchema,
38
- options,
39
- );
40
-
41
- return result;
42
- }
43
- }
44
-
45
- export interface ServerClient {
46
- client: Client;
47
- callStreamableTool: (tool: string, args: unknown) => Promise<Response>;
48
- }
49
- export const createServerClient = async (
50
- mcpServer: { connection: MCPConnection; name?: string },
51
- signal?: AbortSignal,
52
- extraHeaders?: Record<string, string>,
53
- ): Promise<ServerClient> => {
54
- const transport = createTransport(mcpServer.connection, signal, extraHeaders);
55
-
56
- if (!transport) {
57
- throw new Error("Unknown MCP connection type");
58
- }
59
-
60
- const client = new Client({
61
- name: mcpServer?.name ?? "MCP Client",
62
- version: "1.0.0",
63
- });
64
-
65
- await client.connect(transport);
66
-
67
- return {
68
- client,
69
- callStreamableTool: (tool, args) => {
70
- if (mcpServer.connection.type !== "HTTP") {
71
- throw new Error("HTTP connection required");
72
- }
73
- return fetch(mcpServer.connection.url + `/call-tool/${tool}`, {
74
- method: "POST",
75
- redirect: "manual",
76
- body: JSON.stringify(args),
77
- headers: {
78
- ...extraHeaders,
79
- Authorization: `Bearer ${mcpServer.connection.token}`,
80
- },
81
- });
82
- },
83
- };
84
- };
85
-
86
- export const createTransport = (
87
- connection: MCPConnection,
88
- signal?: AbortSignal,
89
- extraHeaders?: Record<string, string>,
90
- ) => {
91
- if (connection.type === "Websocket") {
92
- return new WebSocketClientTransport(new URL(connection.url));
93
- }
94
-
95
- if (connection.type !== "SSE" && connection.type !== "HTTP") {
96
- return null;
97
- }
98
-
99
- const authHeaders: Record<string, string> = connection.token
100
- ? { authorization: `Bearer ${connection.token}` }
101
- : {};
102
-
103
- const headers: Record<string, string> = {
104
- ...authHeaders,
105
- ...(extraHeaders ?? {}),
106
- ...("headers" in connection ? connection.headers || {} : {}),
107
- };
108
-
109
- if (connection.type === "SSE") {
110
- const config: SSEClientTransportOptions = {
111
- requestInit: { headers, signal },
112
- };
113
-
114
- if (connection.token) {
115
- config.eventSourceInit = {
116
- fetch: (req, init) => {
117
- return fetch(req, {
118
- ...init,
119
- headers: {
120
- ...headers,
121
- Accept: "text/event-stream",
122
- },
123
- signal,
124
- });
125
- },
126
- };
127
- }
128
-
129
- return new SSEClientTransport(new URL(connection.url), config);
130
- }
131
- return new HTTPClientTransport(new URL(connection.url), {
132
- requestInit: {
133
- headers,
134
- signal,
135
- // @ts-ignore - this is a valid option for fetch
136
- credentials: "include",
137
- },
138
- });
139
- };