@solvapay/mcp 0.1.0 → 0.2.0

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.
@@ -0,0 +1,481 @@
1
+ // src/express/oauth-bridge.ts
2
+ import {
3
+ buildAuthInfoFromBearer,
4
+ getOAuthAuthorizationServerResponse,
5
+ getOAuthProtectedResourceResponse,
6
+ McpBearerAuthError,
7
+ resolveOAuthPaths,
8
+ withoutTrailingSlash
9
+ } from "@solvapay/mcp-core";
10
+ var NATIVE_CLIENT_ORIGIN_SCHEMES = ["cursor:", "vscode:", "vscode-webview:", "claude:"];
11
+ function getRequestAuthHeader(req) {
12
+ const header = req.headers?.authorization;
13
+ if (typeof header === "string") return header;
14
+ if (Array.isArray(header)) return header[0] || null;
15
+ return null;
16
+ }
17
+ function getHeader(req, name) {
18
+ const header = req.headers?.[name.toLowerCase()];
19
+ if (typeof header === "string") return header;
20
+ if (Array.isArray(header)) return header[0] || null;
21
+ return null;
22
+ }
23
+ function getRequestJsonRpcId(body) {
24
+ if (body && typeof body === "object" && "id" in body) {
25
+ const id = body.id;
26
+ return id ?? null;
27
+ }
28
+ return null;
29
+ }
30
+ function makeUnauthorizedJsonRpc(id) {
31
+ return {
32
+ jsonrpc: "2.0",
33
+ id,
34
+ error: {
35
+ code: -32001,
36
+ message: "Unauthorized"
37
+ }
38
+ };
39
+ }
40
+ function setMcpChallengeHeader(res, publicBaseUrl, protectedResourcePath) {
41
+ res.setHeader(
42
+ "WWW-Authenticate",
43
+ `Bearer resource_metadata="${withoutTrailingSlash(publicBaseUrl)}${protectedResourcePath}"`
44
+ );
45
+ }
46
+ function getRequestQuery(req) {
47
+ const raw = req.url ?? req.path ?? "";
48
+ const qIndex = raw.indexOf("?");
49
+ return qIndex === -1 ? "" : raw.slice(qIndex);
50
+ }
51
+ function isNativeClientOrigin(origin) {
52
+ try {
53
+ const url = new URL(origin);
54
+ return NATIVE_CLIENT_ORIGIN_SCHEMES.includes(
55
+ url.protocol
56
+ );
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+ function applyCorsHeaders(req, res) {
62
+ const origin = getHeader(req, "origin");
63
+ if (!origin) return;
64
+ if (isNativeClientOrigin(origin)) {
65
+ res.setHeader("Access-Control-Allow-Origin", origin);
66
+ res.setHeader("Vary", "Origin");
67
+ }
68
+ }
69
+ function handlePreflight(req, res) {
70
+ applyCorsHeaders(req, res);
71
+ const requestedMethod = getHeader(req, "access-control-request-method") ?? "POST";
72
+ const requestedHeaders = getHeader(req, "access-control-request-headers") ?? "authorization, content-type";
73
+ res.setHeader("Access-Control-Allow-Methods", `${requestedMethod}, OPTIONS`);
74
+ res.setHeader("Access-Control-Allow-Headers", requestedHeaders);
75
+ res.setHeader("Access-Control-Max-Age", "600");
76
+ res.status(204);
77
+ if (typeof res.end === "function") {
78
+ res.end();
79
+ } else {
80
+ res.json({});
81
+ }
82
+ }
83
+ async function readJsonFromResponse(upstream) {
84
+ const text = await upstream.text();
85
+ if (!text) return { body: {}, text: "" };
86
+ try {
87
+ return { body: JSON.parse(text), text };
88
+ } catch {
89
+ return { body: text, text };
90
+ }
91
+ }
92
+ async function relayJsonResponse(upstream, res) {
93
+ const { body, text } = await readJsonFromResponse(upstream);
94
+ res.status(upstream.status);
95
+ const contentType = upstream.headers.get("content-type");
96
+ if (contentType) res.setHeader("Content-Type", contentType);
97
+ if (text === "" && upstream.status === 204) {
98
+ if (typeof res.end === "function") {
99
+ res.end();
100
+ return;
101
+ }
102
+ }
103
+ res.json(body);
104
+ }
105
+ function hasOAuthErrorShape(body) {
106
+ return body !== null && typeof body === "object" && typeof body.error === "string";
107
+ }
108
+ function extractZodErrors(body) {
109
+ const errs = body.errors;
110
+ if (!Array.isArray(errs)) return [];
111
+ return errs.filter((e) => !!e && typeof e === "object");
112
+ }
113
+ function deriveOAuthErrorCode(status, nestBody) {
114
+ if (status === 401 || status === 403) return "invalid_client";
115
+ if (status >= 500) return "server_error";
116
+ const zodErrors = extractZodErrors(nestBody);
117
+ const touches = (field) => zodErrors.some((e) => {
118
+ const path = e.path;
119
+ return Array.isArray(path) && path.includes(field);
120
+ });
121
+ if (touches("grant_type")) {
122
+ const grantTypeErr = zodErrors.find((e) => {
123
+ const path = e.path;
124
+ return Array.isArray(path) && path.includes("grant_type");
125
+ });
126
+ const received = grantTypeErr && grantTypeErr.received;
127
+ if (received !== "undefined" && received !== void 0 && received !== "") {
128
+ return "unsupported_grant_type";
129
+ }
130
+ return "invalid_request";
131
+ }
132
+ if (touches("code") || touches("refresh_token")) return "invalid_grant";
133
+ if (touches("scope")) return "invalid_scope";
134
+ if (touches("client_id") || touches("client_secret")) return "invalid_client";
135
+ return "invalid_request";
136
+ }
137
+ function buildErrorDescription(nestBody) {
138
+ const zodErrors = extractZodErrors(nestBody);
139
+ if (zodErrors.length > 0) {
140
+ const parts = zodErrors.map((e) => {
141
+ const path = e.path;
142
+ const message2 = e.message;
143
+ const pathStr = Array.isArray(path) ? path.filter((p) => typeof p === "string").join(".") : "";
144
+ const msgStr = typeof message2 === "string" ? message2 : "";
145
+ if (pathStr && msgStr) return `${pathStr}: ${msgStr}`;
146
+ return pathStr || msgStr;
147
+ }).filter(Boolean);
148
+ if (parts.length > 0) return parts.join("; ");
149
+ }
150
+ const message = nestBody.message;
151
+ if (typeof message === "string") return message;
152
+ if (Array.isArray(message)) {
153
+ const strings = message.filter((m) => typeof m === "string");
154
+ if (strings.length > 0) return strings.join("; ");
155
+ }
156
+ return void 0;
157
+ }
158
+ function toOAuthErrorBody(body, text, status) {
159
+ if (hasOAuthErrorShape(body)) return body;
160
+ if (body && typeof body === "object") {
161
+ const nestBody = body;
162
+ const error = deriveOAuthErrorCode(status, nestBody);
163
+ const error_description = buildErrorDescription(nestBody);
164
+ return error_description ? { error, error_description } : { error };
165
+ }
166
+ const fallbackError = status >= 500 ? "server_error" : "invalid_request";
167
+ const description = typeof text === "string" && text.length > 0 && text.length < 500 ? text : void 0;
168
+ return description ? { error: fallbackError, error_description: description } : { error: fallbackError };
169
+ }
170
+ async function relayOAuthJsonResponse(upstream, res) {
171
+ if (upstream.ok || upstream.status === 204) {
172
+ await relayJsonResponse(upstream, res);
173
+ return;
174
+ }
175
+ const { body, text } = await readJsonFromResponse(upstream);
176
+ const normalized = toOAuthErrorBody(body, text, upstream.status);
177
+ res.status(upstream.status);
178
+ res.setHeader("Content-Type", "application/json");
179
+ res.json(normalized);
180
+ }
181
+ function sendUpstreamError(res, _error) {
182
+ res.status(502);
183
+ res.setHeader("Content-Type", "application/json");
184
+ res.json({ error: "upstream_unreachable" });
185
+ }
186
+ function serializeRegisterBody(body) {
187
+ if (typeof body === "string") return body;
188
+ if (body instanceof Uint8Array) return Buffer.from(body).toString("utf8");
189
+ return JSON.stringify(body ?? {});
190
+ }
191
+ function serializeFormBody(body) {
192
+ if (typeof body === "string") return body;
193
+ if (body instanceof Uint8Array) return Buffer.from(body).toString("utf8");
194
+ if (body && typeof body === "object") {
195
+ const params = new URLSearchParams();
196
+ for (const [key, value] of Object.entries(body)) {
197
+ if (value === void 0 || value === null) continue;
198
+ if (Array.isArray(value)) {
199
+ for (const entry of value) params.append(key, String(entry));
200
+ } else {
201
+ params.append(key, String(value));
202
+ }
203
+ }
204
+ return params.toString();
205
+ }
206
+ return "";
207
+ }
208
+ function serializeRequestBody(contentType, body) {
209
+ if (contentType && contentType.includes("application/x-www-form-urlencoded")) {
210
+ return serializeFormBody(body);
211
+ }
212
+ if (typeof body === "string") return body;
213
+ if (body instanceof Uint8Array) return Buffer.from(body).toString("utf8");
214
+ return JSON.stringify(body ?? {});
215
+ }
216
+ function createOAuthRegisterHandler(options) {
217
+ const path = options.path ?? "/oauth/register";
218
+ const api = withoutTrailingSlash(options.apiBaseUrl);
219
+ const upstream = `${api}/v1/customer/auth/register?product_ref=${encodeURIComponent(options.productRef)}`;
220
+ return async (req, res, next) => {
221
+ if (req.path !== path) {
222
+ next();
223
+ return;
224
+ }
225
+ if (req.method === "OPTIONS") {
226
+ handlePreflight(req, res);
227
+ return;
228
+ }
229
+ if (req.method !== "POST") {
230
+ next();
231
+ return;
232
+ }
233
+ try {
234
+ const response = await fetch(upstream, {
235
+ method: "POST",
236
+ headers: { "content-type": "application/json" },
237
+ body: serializeRegisterBody(req.body)
238
+ });
239
+ applyCorsHeaders(req, res);
240
+ await relayJsonResponse(response, res);
241
+ } catch (error) {
242
+ applyCorsHeaders(req, res);
243
+ sendUpstreamError(res, error);
244
+ }
245
+ };
246
+ }
247
+ function createOAuthAuthorizeHandler(options) {
248
+ const path = options.path ?? "/oauth/authorize";
249
+ const api = withoutTrailingSlash(options.apiBaseUrl);
250
+ return (req, res, next) => {
251
+ if (req.path !== path) {
252
+ next();
253
+ return;
254
+ }
255
+ if (req.method === "OPTIONS") {
256
+ handlePreflight(req, res);
257
+ return;
258
+ }
259
+ if (req.method !== "GET") {
260
+ next();
261
+ return;
262
+ }
263
+ const query = getRequestQuery(req);
264
+ const location = `${api}/v1/customer/auth/authorize${query}`;
265
+ res.setHeader("Location", location);
266
+ res.status(302);
267
+ if (typeof res.end === "function") {
268
+ res.end();
269
+ return;
270
+ }
271
+ res.json({});
272
+ };
273
+ }
274
+ function createOAuthTokenHandler(options) {
275
+ const path = options.path ?? "/oauth/token";
276
+ const api = withoutTrailingSlash(options.apiBaseUrl);
277
+ const upstream = `${api}/v1/customer/auth/token`;
278
+ return async (req, res, next) => {
279
+ if (req.path !== path) {
280
+ next();
281
+ return;
282
+ }
283
+ if (req.method === "OPTIONS") {
284
+ handlePreflight(req, res);
285
+ return;
286
+ }
287
+ if (req.method !== "POST") {
288
+ next();
289
+ return;
290
+ }
291
+ const contentType = getHeader(req, "content-type") ?? "application/x-www-form-urlencoded";
292
+ const authorization = getHeader(req, "authorization");
293
+ const headers = { "content-type": contentType };
294
+ if (authorization) headers["authorization"] = authorization;
295
+ try {
296
+ const response = await fetch(upstream, {
297
+ method: "POST",
298
+ headers,
299
+ body: serializeRequestBody(contentType, req.body)
300
+ });
301
+ applyCorsHeaders(req, res);
302
+ await relayOAuthJsonResponse(response, res);
303
+ } catch (error) {
304
+ applyCorsHeaders(req, res);
305
+ sendUpstreamError(res, error);
306
+ }
307
+ };
308
+ }
309
+ function createOAuthRevokeHandler(options) {
310
+ const path = options.path ?? "/oauth/revoke";
311
+ const api = withoutTrailingSlash(options.apiBaseUrl);
312
+ const upstream = `${api}/v1/customer/auth/revoke`;
313
+ return async (req, res, next) => {
314
+ if (req.path !== path) {
315
+ next();
316
+ return;
317
+ }
318
+ if (req.method === "OPTIONS") {
319
+ handlePreflight(req, res);
320
+ return;
321
+ }
322
+ if (req.method !== "POST") {
323
+ next();
324
+ return;
325
+ }
326
+ const contentType = getHeader(req, "content-type") ?? "application/x-www-form-urlencoded";
327
+ const authorization = getHeader(req, "authorization");
328
+ const headers = { "content-type": contentType };
329
+ if (authorization) headers["authorization"] = authorization;
330
+ try {
331
+ const response = await fetch(upstream, {
332
+ method: "POST",
333
+ headers,
334
+ body: serializeRequestBody(contentType, req.body)
335
+ });
336
+ applyCorsHeaders(req, res);
337
+ await relayOAuthJsonResponse(response, res);
338
+ } catch (error) {
339
+ applyCorsHeaders(req, res);
340
+ sendUpstreamError(res, error);
341
+ }
342
+ };
343
+ }
344
+ function createMcpOAuthBridge(options) {
345
+ const {
346
+ publicBaseUrl,
347
+ apiBaseUrl,
348
+ productRef,
349
+ mcpPath = "/mcp",
350
+ requireAuth = true,
351
+ authInfo,
352
+ protectedResourcePath = "/.well-known/oauth-protected-resource",
353
+ authorizationServerPath = "/.well-known/oauth-authorization-server",
354
+ oauthPaths
355
+ } = options;
356
+ const paths = resolveOAuthPaths(oauthPaths);
357
+ const openidDiscoveryMiddleware = (req, res, next) => {
358
+ if (req.method !== "GET" || req.path !== "/.well-known/openid-configuration") {
359
+ next();
360
+ return;
361
+ }
362
+ applyCorsHeaders(req, res);
363
+ res.status(404);
364
+ if (typeof res.end === "function") {
365
+ res.end();
366
+ } else {
367
+ res.json({ error: "not_found" });
368
+ }
369
+ };
370
+ const protectedResourceMiddleware = (req, res, next) => {
371
+ if (req.method !== "GET" || req.path !== protectedResourcePath) {
372
+ next();
373
+ return;
374
+ }
375
+ res.json(getOAuthProtectedResourceResponse(publicBaseUrl));
376
+ };
377
+ const authorizationServerMiddleware = (req, res, next) => {
378
+ if (req.method !== "GET" || req.path !== authorizationServerPath) {
379
+ next();
380
+ return;
381
+ }
382
+ if (!productRef) {
383
+ res.status(500).json({ error: "SOLVAPAY_PRODUCT_REF missing" });
384
+ return;
385
+ }
386
+ res.json(
387
+ getOAuthAuthorizationServerResponse({
388
+ publicBaseUrl,
389
+ paths
390
+ })
391
+ );
392
+ };
393
+ const registerMiddleware = createOAuthRegisterHandler({
394
+ apiBaseUrl,
395
+ productRef,
396
+ path: paths.register
397
+ });
398
+ const authorizeMiddleware = createOAuthAuthorizeHandler({
399
+ apiBaseUrl,
400
+ path: paths.authorize
401
+ });
402
+ const tokenMiddleware = createOAuthTokenHandler({ apiBaseUrl, path: paths.token });
403
+ const revokeMiddleware = createOAuthRevokeHandler({ apiBaseUrl, path: paths.revoke });
404
+ const mcpAuthMiddleware = (req, res, next) => {
405
+ if (req.path !== mcpPath) {
406
+ next();
407
+ return;
408
+ }
409
+ if (req.method && req.method !== "POST" && req.method !== "OPTIONS") {
410
+ applyCorsHeaders(req, res);
411
+ res.setHeader("Allow", "POST, OPTIONS");
412
+ res.status(405);
413
+ if (typeof res.end === "function") {
414
+ res.end();
415
+ } else {
416
+ res.json({ error: "method_not_allowed" });
417
+ }
418
+ return;
419
+ }
420
+ const authHeader = getRequestAuthHeader(req);
421
+ const id = getRequestJsonRpcId(req.body);
422
+ if (!authHeader && !requireAuth) {
423
+ next();
424
+ return;
425
+ }
426
+ try {
427
+ const auth = buildAuthInfoFromBearer(authHeader, authInfo);
428
+ if (!auth) {
429
+ throw new McpBearerAuthError("Missing bearer token");
430
+ }
431
+ req.auth = auth;
432
+ next();
433
+ } catch {
434
+ applyCorsHeaders(req, res);
435
+ res.setHeader("Access-Control-Expose-Headers", "WWW-Authenticate");
436
+ setMcpChallengeHeader(res, publicBaseUrl, protectedResourcePath);
437
+ if (req.method === "POST") {
438
+ res.status(401).json(makeUnauthorizedJsonRpc(id));
439
+ return;
440
+ }
441
+ res.status(401).json({ error: "Unauthorized" });
442
+ }
443
+ };
444
+ return [
445
+ openidDiscoveryMiddleware,
446
+ protectedResourceMiddleware,
447
+ authorizationServerMiddleware,
448
+ registerMiddleware,
449
+ authorizeMiddleware,
450
+ tokenMiddleware,
451
+ revokeMiddleware,
452
+ mcpAuthMiddleware
453
+ ];
454
+ }
455
+
456
+ // src/express/index.ts
457
+ import {
458
+ getOAuthAuthorizationServerResponse as getOAuthAuthorizationServerResponse2,
459
+ getOAuthProtectedResourceResponse as getOAuthProtectedResourceResponse2,
460
+ buildAuthInfoFromBearer as buildAuthInfoFromBearer2,
461
+ McpBearerAuthError as McpBearerAuthError2,
462
+ decodeJwtPayload,
463
+ extractBearerToken,
464
+ getCustomerRefFromBearerAuthHeader,
465
+ getCustomerRefFromJwtPayload
466
+ } from "@solvapay/mcp-core";
467
+ export {
468
+ McpBearerAuthError2 as McpBearerAuthError,
469
+ buildAuthInfoFromBearer2 as buildAuthInfoFromBearer,
470
+ createMcpOAuthBridge,
471
+ createOAuthAuthorizeHandler,
472
+ createOAuthRegisterHandler,
473
+ createOAuthRevokeHandler,
474
+ createOAuthTokenHandler,
475
+ decodeJwtPayload,
476
+ extractBearerToken,
477
+ getCustomerRefFromBearerAuthHeader,
478
+ getCustomerRefFromJwtPayload,
479
+ getOAuthAuthorizationServerResponse2 as getOAuthAuthorizationServerResponse,
480
+ getOAuthProtectedResourceResponse2 as getOAuthProtectedResourceResponse
481
+ };