@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 +3 -5
- package/src/index.ts +29 -1
- package/src/oauth.ts +288 -27
- package/src/proxy.ts +1 -157
- package/src/http-client-transport.ts +0 -1
- package/src/mcp-client.ts +0 -139
package/package.json
CHANGED
|
@@ -1,24 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/runtime",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
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.
|
|
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 (
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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 (
|
|
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
|
|
204
|
+
error_description: "Missing code or state",
|
|
71
205
|
},
|
|
72
206
|
{ status: 400 },
|
|
73
207
|
);
|
|
74
208
|
}
|
|
75
209
|
|
|
76
210
|
try {
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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: "
|
|
95
|
-
error_description: "Failed to
|
|
350
|
+
error: "server_error",
|
|
351
|
+
error_description: "Failed to process token request",
|
|
96
352
|
},
|
|
97
|
-
{ status:
|
|
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 (
|
|
174
|
-
console.error("Client registration 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
|
-
|
|
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
|
-
};
|