@better-auth/mcp 1.7.0-rc.2 → 1.7.0-rc.3

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/README.md CHANGED
@@ -2,19 +2,43 @@
2
2
 
3
3
  Model Context Protocol (MCP) plugin for [Better Auth](https://www.better-auth.com).
4
4
 
5
- `mcp()` turns your Better Auth app into an OAuth 2.1 authorization server for MCP
6
- clients, built on [`@better-auth/oauth-provider`](https://www.better-auth.com/docs/plugins/oauth-provider).
7
- It serves the RFC 9728 protected resource metadata so MCP clients can discover it.
8
- To protect an MCP route, wrap its handler with `requireMcpAuth` (or `mcpHandler`),
9
- which verifies bearer tokens against the published JWKS.
5
+ `mcp()` turns your Better Auth app into an OAuth 2.1 authorization server and
6
+ protected resource for MCP clients, built on
7
+ [`@better-auth/oauth-provider`](https://www.better-auth.com/docs/plugins/oauth-provider).
8
+ It serves RFC 9728 protected resource metadata and binds issued tokens to the
9
+ configured resource. Compose it with `cimd()` for the MCP 2026-07-28 Client ID
10
+ Metadata Document flow, which pins CIMD draft-00 through an explicit metadata
11
+ profile. Dynamic Client Registration is disabled unless explicitly enabled.
12
+
13
+ `@better-auth/mcp` owns authorization, not MCP protocol transport. Serve MCP
14
+ 2026-07-28 requests with version 2 of the official
15
+ [`@modelcontextprotocol/server`](https://www.npmjs.com/package/@modelcontextprotocol/server)
16
+ package, configure `createMcpHandler` with `legacy: "reject"`, mount it behind
17
+ `requireMcpAuth`, and expose only the HTTP `POST` route. The modern protocol
18
+ handles each request independently and does not need a Redis-backed MCP
19
+ session store. Multi-instance `subscriptions/listen` deployments can supply a
20
+ shared SDK event bus without introducing protocol-level sessions.
10
21
 
11
22
  ```ts
12
23
  import { betterAuth } from "better-auth";
13
24
  import { jwt } from "better-auth/plugins";
25
+ import { cimd } from "@better-auth/cimd";
26
+ import { fetchClientMetadataResource } from "@better-auth/cimd/node";
14
27
  import { mcp } from "@better-auth/mcp";
15
28
 
16
29
  export const auth = betterAuth({
17
- plugins: [jwt(), mcp({ loginPage: "/login", consentPage: "/consent" })],
30
+ plugins: [
31
+ jwt(),
32
+ mcp({
33
+ loginPage: "/login",
34
+ consentPage: "/consent",
35
+ resource: "https://api.example.com/mcp",
36
+ }),
37
+ cimd({
38
+ fetchClientMetadataResource,
39
+ metadataProfile: "mcp-2026-07-28",
40
+ }),
41
+ ],
18
42
  });
19
43
  ```
20
44
 
package/dist/index.d.mts CHANGED
@@ -1,21 +1,54 @@
1
1
  import { OAuthOptions, Scope, oauthProvider } from "@better-auth/oauth-provider";
2
- import { DpopReplayReservations, DpopReplayStore, verifyAccessTokenRequest } from "better-auth/oauth2";
3
- import { JWTPayload } from "jose";
2
+ import { DpopReplayReservations, DpopReplayStore } from "better-auth/oauth2";
4
3
  import { Awaitable } from "@better-auth/core";
4
+ import { JWTPayload, JWTVerifyOptions } from "jose";
5
5
  import { BetterAuthOptions } from "better-auth/types";
6
6
  //#region src/handler.d.ts
7
+ interface McpProtectedRequestHandlerOptions {
8
+ /** Expected authorization-server issuer for the access token. */
9
+ issuer: string;
10
+ /** Canonical MCP protected-resource URL expected in the token audience. */
11
+ audience: string;
12
+ /**
13
+ * Additional JOSE verification constraints. `issuer` and `audience` remain
14
+ * authoritative from the top-level fields.
15
+ */
16
+ jwtVerifyOptions?: Omit<JWTVerifyOptions, "issuer" | "audience">;
17
+ /** URL of the authorization server's JSON Web Key Set. */
18
+ jwksUrl?: string;
19
+ /** Remote introspection settings for opaque or remotely checked tokens. */
20
+ remoteVerify?: {
21
+ introspectUrl: string;
22
+ clientId: string;
23
+ clientSecret: string;
24
+ force?: boolean;
25
+ allowMissingAudience?: boolean;
26
+ };
27
+ /** Scopes every accepted access token must satisfy. */
28
+ requiredScopes?: readonly string[];
29
+ /**
30
+ * Scopes to advertise in unauthenticated `WWW-Authenticate` challenges.
31
+ * Defaults to `requiredScopes`.
32
+ */
33
+ challengeScopes?: readonly string[];
34
+ /** Custom required-scope matcher. Defaults to exact membership. */
35
+ isScopeSatisfied?: (requiredScope: string, grantedScopes: ReadonlySet<string>) => boolean;
36
+ /** DPoP proof validation and replay-protection settings. */
37
+ dpop?: {
38
+ proofMaxAgeSeconds?: number;
39
+ signingAlgorithms?: readonly string[];
40
+ replayStore?: DpopReplayStore;
41
+ };
42
+ }
7
43
  /**
8
44
  * A request middleware handler that verifies an MCP access token and responds
9
45
  * with an RFC 9728 `WWW-Authenticate` header for unauthenticated requests.
46
+ * When `options.requiredScopes` is set, tokens missing a required scope receive
47
+ * a 403 with an RFC 6750 `insufficient_scope` challenge naming them instead.
10
48
  *
11
49
  * @external
12
50
  */
13
- declare const mcpHandler: (
14
- /** Verifier options. `audience` must match the protected resource identifier. */
15
- verifyOptions: Parameters<typeof verifyAccessTokenRequest>[1], handler: (req: Request, jwt: JWTPayload) => Awaitable<Response>, opts?: {
16
- /** Maps non-url (ie urn, client) resources to resource_metadata */
17
- resourceMetadataMappings: Record<string, string>;
18
- }) => (req: Request) => Promise<Response>;
51
+ declare const createMcpProtectedRequestHandler: (options: McpProtectedRequestHandlerOptions, handler: (request: Request, accessTokenClaims: JWTPayload) => Awaitable<Response>) => (request: Request) => Promise<Response>;
19
52
  //#endregion
20
53
  //#region src/plugin.d.ts
21
54
  /**
@@ -28,17 +61,22 @@ interface McpOptions extends OAuthOptions<Scope[]> {
28
61
  * token response for the same effective scopes, requested resources, and
29
62
  * sender constraint.
30
63
  *
31
- * MCP overrides the OAuth Provider default because native/public MCP clients
32
- * can have multiple local sessions racing the same refresh token. Set to `0`
33
- * to keep strict replay handling.
64
+ * OAuth Provider remains strict by default. MCP overrides that default for
65
+ * every client configured through this plugin so a retried refresh can
66
+ * recover the response produced by an earlier request. Set to `0` to disable
67
+ * the overlap window and keep strict replay handling.
34
68
  *
35
69
  * @default 30
36
70
  */
37
71
  refreshTokenReuseInterval?: OAuthOptions<Scope[]>["refreshTokenReuseInterval"];
38
72
  /**
39
- * The protected resource identifier (RFC 8707 / RFC 9728) that access tokens
40
- * are bound to. Published as `resource` in the protected resource metadata,
41
- * added to `resources`, and used as the expected token audience.
73
+ * The canonical protected resource identifier (RFC 8707 / RFC 9728) for this
74
+ * MCP server. Issued tokens are audience-bound to it, and it is published as
75
+ * `resource` in the protected resource metadata, added to `resources`, and
76
+ * used as the expected token audience.
77
+ *
78
+ * Must be an HTTPS URL with no query, fragment, or credentials. HTTP is
79
+ * accepted only on loopback hosts for local development.
42
80
  */
43
81
  resource: string;
44
82
  }
@@ -46,12 +84,19 @@ interface McpOptions extends OAuthOptions<Scope[]> {
46
84
  * Model Context Protocol authorization server.
47
85
  *
48
86
  * `mcp()` is the OAuth 2.1 / OIDC provider ({@link oauthProvider}) configured for
49
- * MCP: it enables dynamic client registration, binds issued tokens to the MCP
50
- * `resource`, and, as the resource server, serves the RFC 9728 protected resource
51
- * metadata so MCP clients discover and use it through standard OAuth discovery.
52
- * It also defaults `refreshTokenReuseInterval` to 30 seconds for native/public
53
- * MCP clients that may retry a refresh after a local process loses the rotated
54
- * response.
87
+ * MCP: it binds issued tokens to the MCP `resource`, links that resource to
88
+ * newly registered clients, and, as the resource server, serves the RFC 9728
89
+ * protected resource metadata so MCP clients discover and use it through
90
+ * standard OAuth discovery. Client registration is opt-in: compose with
91
+ * `cimd()` for Client ID Metadata Documents, or explicitly enable the OAuth
92
+ * Provider dynamic-registration options.
93
+ * It also defaults `refreshTokenReuseInterval` to 30 seconds for all MCP
94
+ * clients, allowing a retried refresh to recover a rotated response. OAuth
95
+ * Provider remains strict by default; set the MCP option to `0` to disable the
96
+ * overlap window.
97
+ * MCP 2026-07-28 pins Client ID Metadata Documents draft-00. Configure the
98
+ * CIMD plugin with `metadataProfile: "mcp-2026-07-28"` and an application-owned
99
+ * metadata-resource transport.
55
100
  * Because it is the OAuth provider, it cannot be combined with a separate
56
101
  * {@link oauthProvider}.
57
102
  *
@@ -59,7 +104,9 @@ interface McpOptions extends OAuthOptions<Scope[]> {
59
104
  * ```ts
60
105
  * import { betterAuth } from "better-auth";
61
106
  * import { jwt } from "better-auth/plugins";
107
+ * import { cimd } from "@better-auth/cimd";
62
108
  * import { mcp } from "@better-auth/mcp";
109
+ * import { fetchClientMetadataResource } from "./oauth-network";
63
110
  *
64
111
  * export const auth = betterAuth({
65
112
  * plugins: [
@@ -69,6 +116,10 @@ interface McpOptions extends OAuthOptions<Scope[]> {
69
116
  * consentPage: "/consent",
70
117
  * resource: "https://api.example.com/mcp",
71
118
  * }),
119
+ * cimd({
120
+ * fetchClientMetadataResource,
121
+ * metadataProfile: "mcp-2026-07-28",
122
+ * }),
72
123
  * ],
73
124
  * });
74
125
  * ```
@@ -93,16 +144,20 @@ interface RequireMcpAuthOptions {
93
144
  */
94
145
  jwksUrl?: string;
95
146
  /**
96
- * Space-delimited scopes to advertise in the `WWW-Authenticate` challenge
97
- * (RFC 6750), hinting which scopes the client should request.
147
+ * Scopes to advertise in the `WWW-Authenticate` challenge (RFC 6750),
148
+ * hinting which scopes the client should request. Defaults to the enforced
149
+ * `requiredScopes` when those are set.
98
150
  */
99
- scope?: string;
151
+ challengeScopes?: readonly string[];
100
152
  /**
101
- * Maps a non-URL `resource` (an RFC 8707 `urn:` identifier or a client id) to
102
- * the URL of its protected resource metadata. Required when `resource` is not
103
- * an origin-based URL, so the `WWW-Authenticate` challenge can point at it.
153
+ * Scopes the access token must include, enforced against the token's
154
+ * `scope` claim. A token missing any of them is rejected with a 403 and an
155
+ * RFC 6750 `insufficient_scope` challenge naming every missing scope, so MCP
156
+ * clients can step up their authorization in one round-trip.
104
157
  */
105
- resourceMetadataMappings?: Record<string, string>;
158
+ requiredScopes?: readonly string[];
159
+ /** Custom required-scope matcher. Defaults to exact membership. */
160
+ isScopeSatisfied?: (requiredScope: string, grantedScopes: ReadonlySet<string>) => boolean;
106
161
  /**
107
162
  * DPoP proof validation settings. By default the replay store is backed by
108
163
  * the auth instance's database adapter, so anti-replay holds across multiple
@@ -120,10 +175,15 @@ interface RequireMcpAuthOptions {
120
175
  * audience, and expiry) and forwards the verified JWT payload to the handler.
121
176
  * Unauthenticated requests receive a JSON-RPC 401 with the RFC 9728
122
177
  * `WWW-Authenticate` header so MCP clients can start the authorization flow.
178
+ * Tokens missing a required scope receive a 403 with an RFC 6750
179
+ * `insufficient_scope` challenge naming the missing scopes, so clients can step
180
+ * up their authorization; a handler can raise the same challenge for scopes only
181
+ * it knows about by throwing `createInsufficientScopeError`.
123
182
  *
124
183
  * For a resource server that runs separately from the authorization server, or
125
- * a server using a dynamic `baseURL`, use {@link mcpHandler} with explicit
126
- * verification options instead.
184
+ * a server using a dynamic `baseURL`, use
185
+ * {@link createMcpProtectedRequestHandler} with explicit verification options
186
+ * instead.
127
187
  *
128
188
  * @external
129
189
  */
@@ -133,6 +193,6 @@ declare const requireMcpAuth: <Auth extends {
133
193
  baseURL: string;
134
194
  internalAdapter: DpopReplayReservations;
135
195
  }>;
136
- }>(auth: Auth, handler: (req: Request, jwt: JWTPayload) => Awaitable<Response>, opts?: RequireMcpAuthOptions) => (req: Request) => Promise<Response>;
196
+ }>(auth: Auth, handler: (request: Request, accessTokenClaims: JWTPayload) => Awaitable<Response>, opts?: RequireMcpAuthOptions) => (req: Request) => Promise<Response>;
137
197
  //#endregion
138
- export { type McpOptions, mcp, mcpHandler, requireMcpAuth };
198
+ export { type McpOptions, type McpProtectedRequestHandlerOptions, type RequireMcpAuthOptions, createMcpProtectedRequestHandler, mcp, requireMcpAuth };
package/dist/index.mjs CHANGED
@@ -1,42 +1,85 @@
1
- import { ResourceUriSchema, getIssuer, metadataResponse, oauthProvider, raiseResourceServerChallenge } from "@better-auth/oauth-provider";
2
- import { DPOP_SIGNING_ALGORITHMS, createDpopReplayStore, requestToResourceInput, verifyAccessTokenRequest } from "better-auth/oauth2";
3
- import { APIError } from "better-call";
1
+ import { createResourceServerChallenge, getIssuer, metadataResponse, oauthProvider } from "@better-auth/oauth-provider";
2
+ import { DPOP_SIGNING_ALGORITHMS, createDpopReplayStore, isInsufficientScopeError, requestToResourceInput, verifyAccessTokenRequest } from "better-auth/oauth2";
4
3
  //#region src/handler.ts
4
+ function isLoopbackHost(hostname) {
5
+ const ipv4Octets = hostname.split(".");
6
+ const isIpv4Loopback = ipv4Octets.length === 4 && ipv4Octets[0] === "127" && ipv4Octets.every((octet) => /^\d+$/.test(octet) && Number(octet) <= 255);
7
+ return hostname === "localhost" || hostname === "[::1]" || isIpv4Loopback;
8
+ }
9
+ /**
10
+ * Validates the canonical protected-resource identifier accepted by MCP.
11
+ *
12
+ * @internal
13
+ */
14
+ function validateMcpResource(resource) {
15
+ if (typeof resource !== "string") throw new TypeError("MCP resource must be a single URL string");
16
+ let url;
17
+ try {
18
+ url = new URL(resource);
19
+ } catch {
20
+ throw new TypeError("MCP resource must be an absolute URL");
21
+ }
22
+ if (url.username || url.password) throw new TypeError("MCP resource URL must not contain credentials");
23
+ if (resource.includes("#")) throw new TypeError("MCP resource URL must not contain a fragment");
24
+ if (resource.includes("?")) throw new TypeError("MCP resource URL must not contain a query; to protect a query-carrying resource, verify tokens with verifyAccessTokenRequest and build challenges with createResourceServerChallenge");
25
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopbackHost(url.hostname))) throw new TypeError("MCP resource URL must use HTTPS, except for localhost or loopback IP development URLs");
26
+ return resource;
27
+ }
28
+ function toChallengeResponse(error, resource, opts) {
29
+ const challenge = createResourceServerChallenge(error, resource, opts);
30
+ if (!challenge) throw error;
31
+ const headers = new Headers(challenge.headers);
32
+ headers.set("Content-Type", "application/json");
33
+ return new Response(JSON.stringify({
34
+ jsonrpc: "2.0",
35
+ error: {
36
+ code: -32e3,
37
+ message: challenge.message
38
+ },
39
+ id: null
40
+ }), {
41
+ status: challenge.statusCode,
42
+ headers
43
+ });
44
+ }
5
45
  /**
6
46
  * A request middleware handler that verifies an MCP access token and responds
7
47
  * with an RFC 9728 `WWW-Authenticate` header for unauthenticated requests.
48
+ * When `options.requiredScopes` is set, tokens missing a required scope receive
49
+ * a 403 with an RFC 6750 `insufficient_scope` challenge naming them instead.
8
50
  *
9
51
  * @external
10
52
  */
11
- const mcpHandler = (verifyOptions, handler, opts) => {
12
- return async (req) => {
53
+ const createMcpProtectedRequestHandler = (options, handler) => {
54
+ const resource = validateMcpResource(options.audience);
55
+ const resolvedChallengeOptions = {
56
+ challengeScopes: options.challengeScopes ?? options.requiredScopes,
57
+ dpopSigningAlgorithms: options.dpop?.signingAlgorithms
58
+ };
59
+ const accessTokenVerificationOptions = {
60
+ verifyOptions: {
61
+ ...options.jwtVerifyOptions,
62
+ issuer: options.issuer,
63
+ audience: options.audience
64
+ },
65
+ jwksUrl: options.jwksUrl,
66
+ remoteVerify: options.remoteVerify,
67
+ requiredScopes: options.requiredScopes,
68
+ isScopeSatisfied: options.isScopeSatisfied,
69
+ dpop: options.dpop
70
+ };
71
+ return async (request) => {
72
+ let accessTokenClaims;
13
73
  try {
14
- return handler(req, await verifyAccessTokenRequest(requestToResourceInput(req), verifyOptions));
74
+ accessTokenClaims = await verifyAccessTokenRequest(requestToResourceInput(request), accessTokenVerificationOptions);
15
75
  } catch (error) {
16
- try {
17
- raiseResourceServerChallenge(error, verifyOptions.verifyOptions.audience, {
18
- ...opts,
19
- dpopSigningAlgorithms: verifyOptions.dpop?.signingAlgorithms
20
- });
21
- } catch (err) {
22
- if (err instanceof APIError) {
23
- const headers = new Headers(err.headers);
24
- headers.set("Content-Type", "application/json");
25
- return new Response(JSON.stringify({
26
- jsonrpc: "2.0",
27
- error: {
28
- code: -32e3,
29
- message: err.message
30
- },
31
- id: null
32
- }), {
33
- status: err.statusCode,
34
- headers
35
- });
36
- }
37
- throw new Error(String(err));
38
- }
39
- throw new Error(String(error));
76
+ return toChallengeResponse(error, resource, resolvedChallengeOptions);
77
+ }
78
+ try {
79
+ return await handler(request, accessTokenClaims);
80
+ } catch (error) {
81
+ if (isInsufficientScopeError(error)) return toChallengeResponse(error, resource, resolvedChallengeOptions);
82
+ throw error;
40
83
  }
41
84
  };
42
85
  };
@@ -57,6 +100,7 @@ const appendProtectedResource = (resources, resource) => {
57
100
  if (configuredResources.some((configuredResource) => resourceIdentifier(configuredResource) === resource)) return configuredResources;
58
101
  return [...configuredResources, resource];
59
102
  };
103
+ const appendResourceIdentifier = (resources, resource) => resources?.includes(resource) ? resources : [...resources ?? [], resource];
60
104
  /**
61
105
  * Build the RFC 9728 Protected Resource Metadata document. The MCP server is the
62
106
  * resource server, and its authorization server is this same provider, so
@@ -82,12 +126,19 @@ const buildResourceServerMetadata = (ctx, providerOptions, resource) => {
82
126
  * Model Context Protocol authorization server.
83
127
  *
84
128
  * `mcp()` is the OAuth 2.1 / OIDC provider ({@link oauthProvider}) configured for
85
- * MCP: it enables dynamic client registration, binds issued tokens to the MCP
86
- * `resource`, and, as the resource server, serves the RFC 9728 protected resource
87
- * metadata so MCP clients discover and use it through standard OAuth discovery.
88
- * It also defaults `refreshTokenReuseInterval` to 30 seconds for native/public
89
- * MCP clients that may retry a refresh after a local process loses the rotated
90
- * response.
129
+ * MCP: it binds issued tokens to the MCP `resource`, links that resource to
130
+ * newly registered clients, and, as the resource server, serves the RFC 9728
131
+ * protected resource metadata so MCP clients discover and use it through
132
+ * standard OAuth discovery. Client registration is opt-in: compose with
133
+ * `cimd()` for Client ID Metadata Documents, or explicitly enable the OAuth
134
+ * Provider dynamic-registration options.
135
+ * It also defaults `refreshTokenReuseInterval` to 30 seconds for all MCP
136
+ * clients, allowing a retried refresh to recover a rotated response. OAuth
137
+ * Provider remains strict by default; set the MCP option to `0` to disable the
138
+ * overlap window.
139
+ * MCP 2026-07-28 pins Client ID Metadata Documents draft-00. Configure the
140
+ * CIMD plugin with `metadataProfile: "mcp-2026-07-28"` and an application-owned
141
+ * metadata-resource transport.
91
142
  * Because it is the OAuth provider, it cannot be combined with a separate
92
143
  * {@link oauthProvider}.
93
144
  *
@@ -95,7 +146,9 @@ const buildResourceServerMetadata = (ctx, providerOptions, resource) => {
95
146
  * ```ts
96
147
  * import { betterAuth } from "better-auth";
97
148
  * import { jwt } from "better-auth/plugins";
149
+ * import { cimd } from "@better-auth/cimd";
98
150
  * import { mcp } from "@better-auth/mcp";
151
+ * import { fetchClientMetadataResource } from "./oauth-network";
99
152
  *
100
153
  * export const auth = betterAuth({
101
154
  * plugins: [
@@ -105,19 +158,22 @@ const buildResourceServerMetadata = (ctx, providerOptions, resource) => {
105
158
  * consentPage: "/consent",
106
159
  * resource: "https://api.example.com/mcp",
107
160
  * }),
161
+ * cimd({
162
+ * fetchClientMetadataResource,
163
+ * metadataProfile: "mcp-2026-07-28",
164
+ * }),
108
165
  * ],
109
166
  * });
110
167
  * ```
111
168
  */
112
169
  const mcp = (options) => {
113
- const { resource, refreshTokenReuseInterval = 30, ...oauthOptions } = options;
114
- ResourceUriSchema.parse(resource);
170
+ const { resource: configuredResource, refreshTokenReuseInterval = 30, ...oauthOptions } = options;
171
+ const resource = validateMcpResource(configuredResource);
115
172
  const provider = oauthProvider({
116
- allowDynamicClientRegistration: true,
117
- allowUnauthenticatedClientRegistration: true,
118
173
  refreshTokenReuseInterval,
119
174
  ...oauthOptions,
120
- resources: appendProtectedResource(oauthOptions.resources, resource)
175
+ resources: appendProtectedResource(oauthOptions.resources, resource),
176
+ clientRegistrationDefaultResources: appendResourceIdentifier(oauthOptions.clientRegistrationDefaultResources, resource)
121
177
  });
122
178
  const serveProviderDiscovery = provider.onRequest;
123
179
  return {
@@ -149,70 +205,46 @@ const mcp = (options) => {
149
205
  };
150
206
  //#endregion
151
207
  //#region src/require-mcp-auth.ts
152
- const unauthorized = (error) => {
153
- const headers = new Headers(error.headers);
154
- headers.set("Content-Type", "application/json");
155
- return new Response(JSON.stringify({
156
- jsonrpc: "2.0",
157
- error: {
158
- code: -32e3,
159
- message: error.message
160
- },
161
- id: null
162
- }), {
163
- status: error.statusCode,
164
- headers
165
- });
166
- };
167
208
  /**
168
209
  * Protects an MCP server route handler. Verifies the bearer access token
169
210
  * against the authorization server's JWKS (checking signature, issuer,
170
211
  * audience, and expiry) and forwards the verified JWT payload to the handler.
171
212
  * Unauthenticated requests receive a JSON-RPC 401 with the RFC 9728
172
213
  * `WWW-Authenticate` header so MCP clients can start the authorization flow.
214
+ * Tokens missing a required scope receive a 403 with an RFC 6750
215
+ * `insufficient_scope` challenge naming the missing scopes, so clients can step
216
+ * up their authorization; a handler can raise the same challenge for scopes only
217
+ * it knows about by throwing `createInsufficientScopeError`.
173
218
  *
174
219
  * For a resource server that runs separately from the authorization server, or
175
- * a server using a dynamic `baseURL`, use {@link mcpHandler} with explicit
176
- * verification options instead.
220
+ * a server using a dynamic `baseURL`, use
221
+ * {@link createMcpProtectedRequestHandler} with explicit verification options
222
+ * instead.
177
223
  *
178
224
  * @external
179
225
  */
180
226
  const requireMcpAuth = (auth, handler, opts) => {
181
- if (opts?.resource !== void 0) ResourceUriSchema.parse(opts.resource);
227
+ if (opts?.resource !== void 0) validateMcpResource(opts.resource);
182
228
  return async (req) => {
183
229
  const { baseURL, internalAdapter } = await auth.$context;
184
- if (!baseURL) throw new Error("requireMcpAuth requires a resolvable base URL. For dynamic base URLs use `mcpHandler` with explicit verify options.");
230
+ if (!baseURL) throw new Error("requireMcpAuth requires a resolvable base URL. For dynamic base URLs use `createMcpProtectedRequestHandler` with explicit verification options.");
185
231
  const issuer = opts?.issuer ?? baseURL;
186
232
  const resource = opts?.resource ?? baseURL;
187
233
  const jwksUrl = opts?.jwksUrl ?? `${baseURL}/jwks`;
188
- try {
189
- return handler(req, await verifyAccessTokenRequest(requestToResourceInput(req), {
190
- verifyOptions: {
191
- issuer,
192
- audience: resource
193
- },
194
- jwksUrl,
195
- dpop: {
196
- proofMaxAgeSeconds: opts?.dpop?.proofMaxAgeSeconds,
197
- signingAlgorithms: opts?.dpop?.signingAlgorithms,
198
- replayStore: opts?.dpop?.replayStore ?? createDpopReplayStore(internalAdapter)
199
- }
200
- }));
201
- } catch (error) {
202
- try {
203
- raiseResourceServerChallenge(error, resource, {
204
- scope: opts?.scope,
205
- resourceMetadataMappings: opts?.resourceMetadataMappings,
206
- dpopSigningAlgorithms: opts?.dpop?.signingAlgorithms
207
- });
208
- } catch (challengeError) {
209
- if (challengeError instanceof APIError) return unauthorized(challengeError);
210
- if (challengeError instanceof Error) throw challengeError;
211
- throw new Error(String(challengeError));
234
+ return createMcpProtectedRequestHandler({
235
+ issuer,
236
+ audience: resource,
237
+ requiredScopes: opts?.requiredScopes,
238
+ challengeScopes: opts?.challengeScopes,
239
+ isScopeSatisfied: opts?.isScopeSatisfied,
240
+ jwksUrl,
241
+ dpop: {
242
+ proofMaxAgeSeconds: opts?.dpop?.proofMaxAgeSeconds,
243
+ signingAlgorithms: opts?.dpop?.signingAlgorithms,
244
+ replayStore: opts?.dpop?.replayStore ?? createDpopReplayStore(internalAdapter)
212
245
  }
213
- throw new Error(String(error));
214
- }
246
+ }, handler)(req);
215
247
  };
216
248
  };
217
249
  //#endregion
218
- export { mcp, mcpHandler, requireMcpAuth };
250
+ export { createMcpProtectedRequestHandler, mcp, requireMcpAuth };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@better-auth/mcp",
3
- "version": "1.7.0-rc.2",
3
+ "version": "1.7.0-rc.3",
4
4
  "description": "Model Context Protocol (MCP) plugin for Better Auth",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -36,49 +36,31 @@
36
36
  "dev-source": "./src/index.ts",
37
37
  "types": "./dist/index.d.mts",
38
38
  "default": "./dist/index.mjs"
39
- },
40
- "./client": {
41
- "dev-source": "./src/client/index.ts",
42
- "types": "./dist/client/index.d.mts",
43
- "default": "./dist/client/index.mjs"
44
- },
45
- "./client/adapters": {
46
- "dev-source": "./src/client/adapters.ts",
47
- "types": "./dist/client/adapters.d.mts",
48
- "default": "./dist/client/adapters.mjs"
49
39
  }
50
40
  },
51
41
  "typesVersions": {
52
42
  "*": {
53
43
  "*": [
54
44
  "./dist/index.d.mts"
55
- ],
56
- "client": [
57
- "./dist/client/index.d.mts"
58
- ],
59
- "client/adapters": [
60
- "./dist/client/adapters.d.mts"
61
45
  ]
62
46
  }
63
47
  },
64
48
  "dependencies": {
65
49
  "jose": "^6.1.3",
66
- "@better-auth/oauth-provider": "^1.7.0-rc.2"
50
+ "@better-auth/oauth-provider": "^1.7.0-rc.3"
67
51
  },
68
52
  "devDependencies": {
69
- "@modelcontextprotocol/sdk": "^1.27.1",
53
+ "@modelcontextprotocol/client": "2.0.0",
54
+ "@modelcontextprotocol/server": "2.0.0",
70
55
  "better-call": "1.3.7",
71
- "listhen": "1.9.0",
72
56
  "tsdown": "0.22.7",
73
- "better-auth": "1.7.0-rc.2",
74
- "@better-auth/core": "1.7.0-rc.2"
57
+ "@better-auth/core": "1.7.0-rc.3",
58
+ "better-auth": "1.7.0-rc.3"
75
59
  },
76
60
  "peerDependencies": {
77
- "@better-auth/utils": "0.4.2",
78
- "@better-fetch/fetch": "1.3.1",
79
61
  "better-call": "1.3.7",
80
- "better-auth": "^1.7.0-rc.2",
81
- "@better-auth/core": "^1.7.0-rc.2"
62
+ "@better-auth/core": "^1.7.0-rc.3",
63
+ "better-auth": "^1.7.0-rc.3"
82
64
  },
83
65
  "scripts": {
84
66
  "build": "tsdown",
@@ -1,55 +0,0 @@
1
- import { McpResourceClient, McpResourceClientOptions, McpSession } from "./index.mjs";
2
- //#region src/client/adapters.d.ts
3
- interface HonoContext {
4
- req: {
5
- header: (name: string) => string | undefined;
6
- raw: Request;
7
- };
8
- set: (key: string, value: unknown) => void;
9
- json: (data: unknown, status?: number, headers?: Record<string, string>) => Response;
10
- header: (name: string, value: string) => void;
11
- }
12
- type HonoNext = () => Promise<void>;
13
- type HonoMiddleware = (c: HonoContext, next: HonoNext) => Promise<Response | void>;
14
- interface HonoApp {
15
- get: (path: string, handler: (c: HonoContext) => Promise<Response>) => void;
16
- }
17
- declare function mcpAuthHono(options: McpResourceClientOptions): {
18
- client: McpResourceClient;
19
- middleware: HonoMiddleware;
20
- discoveryRoutes: (app: HonoApp, serverURL: string) => void;
21
- };
22
- declare function mcpAuthOfficial(options: McpResourceClientOptions): {
23
- client: McpResourceClient;
24
- handler: McpResourceClient["handler"];
25
- verifyToken: McpResourceClient["verifyToken"];
26
- };
27
- type OAuthMode = "direct" | "proxy";
28
- interface McpUseUserInfo {
29
- userId: string;
30
- roles?: string[];
31
- permissions?: string[];
32
- scopes?: string;
33
- clientId?: string;
34
- [key: string]: unknown;
35
- }
36
- interface OAuthProvider {
37
- verifyToken(token: string): Promise<{
38
- payload: Record<string, unknown>;
39
- }>;
40
- getUserInfo(payload: Record<string, unknown>): McpUseUserInfo;
41
- getIssuer(): string;
42
- getAuthEndpoint(): string;
43
- getTokenEndpoint(): string;
44
- getScopesSupported(): string[];
45
- getGrantTypesSupported(): string[];
46
- getMode(): OAuthMode;
47
- getRegistrationEndpoint?(): string;
48
- }
49
- interface McpUseBetterAuthConfig {
50
- authURL: string;
51
- getUserInfo?: (payload: Record<string, unknown>) => McpUseUserInfo;
52
- }
53
- declare function mcpAuthMcpUse(config: McpUseBetterAuthConfig): OAuthProvider;
54
- //#endregion
55
- export { type McpResourceClient, type McpResourceClientOptions, type McpSession, McpUseBetterAuthConfig, mcpAuthHono, mcpAuthMcpUse, mcpAuthOfficial };
@@ -1,130 +0,0 @@
1
- import { createMcpResourceClient, makeDpopWWWAuthenticate } from "./index.mjs";
2
- import { DPOP_SIGNING_ALGORITHMS, isDpopBindingError, parseAccessTokenAuthorization } from "better-auth/oauth2";
3
- //#region src/client/adapters.ts
4
- const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource";
5
- function getProtectedResourceMetadataPath(resource) {
6
- const resourceUrl = new URL(resource);
7
- if (resourceUrl.origin === "null") return PROTECTED_RESOURCE_METADATA_PATH;
8
- const resourcePath = resourceUrl.pathname === "/" ? "" : resourceUrl.pathname.replace(/\/$/, "");
9
- return `${PROTECTED_RESOURCE_METADATA_PATH}${resourcePath}`;
10
- }
11
- function getProtectedResourceMetadataURL(resource) {
12
- const resourceUrl = new URL(resource);
13
- if (resourceUrl.origin === "null") throw new Error("MCP resource_metadata requires an origin-based resource URL");
14
- return `${resourceUrl.origin}${getProtectedResourceMetadataPath(resource)}${resourceUrl.search}`;
15
- }
16
- function mcpAuthHono(options) {
17
- const client = createMcpResourceClient(options);
18
- const resourceMetadata = getProtectedResourceMetadataURL(options.resource ?? client.authURL);
19
- const dpopChallenge = makeDpopWWWAuthenticate(options.dpop?.signingAlgorithms ?? DPOP_SIGNING_ALGORITHMS);
20
- const unauthorized = (c, challenge, message) => {
21
- c.header("WWW-Authenticate", challenge);
22
- return c.json({
23
- jsonrpc: "2.0",
24
- error: {
25
- code: -32e3,
26
- message
27
- },
28
- id: null
29
- }, 401);
30
- };
31
- const middleware = async (c, next) => {
32
- const authHeader = c.req.header("Authorization");
33
- let session;
34
- try {
35
- session = await client.verifyRequest(c.req.raw);
36
- } catch (error) {
37
- if (isDpopBindingError(error)) return unauthorized(c, dpopChallenge, "Invalid or expired token");
38
- throw error;
39
- }
40
- if (!session) {
41
- const challenge = parseAccessTokenAuthorization(authHeader)?.scheme === "DPoP" || !!c.req.header("DPoP") ? dpopChallenge : `Bearer resource_metadata="${resourceMetadata}"`;
42
- return unauthorized(c, challenge, authHeader ? "Invalid or expired token" : "Unauthorized: Authentication required");
43
- }
44
- c.set("mcpSession", session);
45
- await next();
46
- };
47
- const discoveryRoutes = (app, serverURL) => {
48
- const discoveryFn = client.discoveryHandler();
49
- const protectedResourceFn = client.protectedResourceHandler(serverURL);
50
- const protectedResourcePaths = /* @__PURE__ */ new Set([PROTECTED_RESOURCE_METADATA_PATH, getProtectedResourceMetadataPath(options.resource ?? client.authURL)]);
51
- app.get("/.well-known/oauth-authorization-server", async (c) => {
52
- const response = await discoveryFn(c.req.raw);
53
- const data = await response.json().catch(() => ({ error: "Invalid response from auth server" }));
54
- return c.json(data, response.status);
55
- });
56
- for (const path of protectedResourcePaths) app.get(path, async (c) => {
57
- const response = await protectedResourceFn(c.req.raw);
58
- const data = await response.json().catch(() => ({ error: "Invalid response from auth server" }));
59
- return c.json(data, response.status);
60
- });
61
- };
62
- return {
63
- client,
64
- middleware,
65
- discoveryRoutes
66
- };
67
- }
68
- function mcpAuthOfficial(options) {
69
- const client = createMcpResourceClient(options);
70
- return {
71
- client,
72
- handler: client.handler,
73
- verifyToken: client.verifyToken
74
- };
75
- }
76
- function mcpAuthMcpUse(config) {
77
- const authURL = normalizeURL(config.authURL);
78
- if (!authURL) throw new Error("Better Auth authURL is required. Pass authURL in config, e.g.: mcpAuthMcpUse({ authURL: 'http://localhost:3000/api/auth' })");
79
- const client = createMcpResourceClient({ authURL });
80
- return {
81
- async verifyToken(token) {
82
- const session = await client.verifyToken(token);
83
- if (!session) throw new Error("Invalid or expired token");
84
- return { payload: session };
85
- },
86
- getUserInfo(payload) {
87
- if (config.getUserInfo) return config.getUserInfo(payload);
88
- const scopes = typeof payload.scope === "string" ? payload.scope.split(" ") : [];
89
- return {
90
- userId: payload.sub,
91
- roles: [],
92
- permissions: scopes,
93
- scopes: payload.scope,
94
- clientId: payload.azp ?? payload.client_id
95
- };
96
- },
97
- getIssuer() {
98
- return authURL;
99
- },
100
- getAuthEndpoint() {
101
- return `${authURL}/oauth2/authorize`;
102
- },
103
- getTokenEndpoint() {
104
- return `${authURL}/oauth2/token`;
105
- },
106
- getScopesSupported() {
107
- return [
108
- "openid",
109
- "profile",
110
- "email",
111
- "offline_access"
112
- ];
113
- },
114
- getGrantTypesSupported() {
115
- return ["authorization_code", "refresh_token"];
116
- },
117
- getMode() {
118
- return "direct";
119
- },
120
- getRegistrationEndpoint() {
121
- return `${authURL}/oauth2/register`;
122
- }
123
- };
124
- }
125
- function normalizeURL(url) {
126
- if (!url || url.trim() === "") return void 0;
127
- return url.endsWith("/") ? url.slice(0, -1) : url;
128
- }
129
- //#endregion
130
- export { mcpAuthHono, mcpAuthMcpUse, mcpAuthOfficial };
@@ -1,53 +0,0 @@
1
- import { VerifyAccessTokenRequestOptions } from "better-auth/oauth2";
2
- import { JWTPayload } from "jose";
3
- //#region src/client/index.d.ts
4
- interface McpResourceClientOptions {
5
- authURL: string;
6
- resource?: string;
7
- allowedOrigin?: string;
8
- fetch?: typeof globalThis.fetch;
9
- dpop?: VerifyAccessTokenRequestOptions["dpop"];
10
- }
11
- interface McpSession extends JWTPayload {
12
- sub?: string;
13
- scope?: string;
14
- client_id?: string;
15
- }
16
- interface NodeLikeRequest {
17
- headers: Record<string, string | string[] | undefined> & {
18
- get?: (name: string) => string | undefined;
19
- authorization?: string;
20
- host?: string;
21
- "x-forwarded-proto"?: string;
22
- };
23
- get?: (name: string) => string | undefined;
24
- method?: string;
25
- originalUrl?: string;
26
- protocol?: string;
27
- url?: string;
28
- mcpSession?: McpSession;
29
- }
30
- interface NodeLikeResponse {
31
- set?: (name: string, value: string) => void;
32
- get?: (name: string) => string | undefined;
33
- setHeader?: (name: string, value: string) => void;
34
- getHeader?: (name: string) => string | number | string[] | undefined;
35
- status?: (code: number) => {
36
- json: (body: unknown) => void;
37
- };
38
- writeHead?: (code: number, headers: Record<string, string>) => void;
39
- end?: (body: string) => void;
40
- }
41
- interface McpResourceClient {
42
- verifyToken: (token: string) => Promise<McpSession | null>;
43
- verifyRequest: (req: Request) => Promise<McpSession | null>;
44
- handler: (fn: (req: Request, session: McpSession) => Response | Promise<Response>) => (req: Request) => Promise<Response>;
45
- discoveryHandler: () => (req: Request) => Promise<Response>;
46
- protectedResourceHandler: (serverURL: string) => (req: Request) => Promise<Response>;
47
- middleware: () => (req: NodeLikeRequest, res: NodeLikeResponse, next: () => void) => Promise<void>;
48
- authURL: string;
49
- }
50
- declare function makeDpopWWWAuthenticate(algorithms: readonly string[]): string;
51
- declare function createMcpResourceClient(options: McpResourceClientOptions): McpResourceClient;
52
- //#endregion
53
- export { McpResourceClient, McpResourceClientOptions, McpSession, createMcpResourceClient, makeDpopWWWAuthenticate };
@@ -1,267 +0,0 @@
1
- import { DPOP_SIGNING_ALGORITHMS, createInMemoryDpopReplayStore, enforceDpopBinding, getDpopJktFromPayload, isDpopBindingError, parseAccessTokenAuthorization } from "better-auth/oauth2";
2
- import { createRemoteJWKSet, jwtVerify } from "jose";
3
- //#region src/client/index.ts
4
- const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource";
5
- function buildCorsHeaders(authURL, allowedOrigin) {
6
- let origin;
7
- if (allowedOrigin) origin = allowedOrigin;
8
- else try {
9
- origin = new URL(authURL).origin;
10
- } catch {
11
- origin = authURL;
12
- }
13
- return {
14
- "Access-Control-Allow-Origin": origin,
15
- "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
16
- "Access-Control-Allow-Headers": "Content-Type, Authorization, DPoP",
17
- "Access-Control-Expose-Headers": "WWW-Authenticate",
18
- "Access-Control-Max-Age": "86400"
19
- };
20
- }
21
- function getProtectedResourceMetadataURL(resource) {
22
- const resourceUrl = new URL(resource);
23
- if (resourceUrl.origin === "null") throw new Error("MCP resource_metadata requires an origin-based resource URL");
24
- const resourcePath = resourceUrl.pathname === "/" ? "" : resourceUrl.pathname.replace(/\/$/, "");
25
- return `${resourceUrl.origin}${PROTECTED_RESOURCE_METADATA_PATH}${resourcePath}${resourceUrl.search}`;
26
- }
27
- function makeWWWAuthenticate(authURL, resource) {
28
- return `Bearer resource_metadata="${getProtectedResourceMetadataURL(resource ?? authURL)}"`;
29
- }
30
- function makeDpopWWWAuthenticate(algorithms) {
31
- return `DPoP algs="${algorithms.map((alg) => alg.replace(/[\r\n"\\]/g, "")).join(" ")}"`;
32
- }
33
- function addExposeHeader(headers, headerName) {
34
- const exposedHeaders = headers["Access-Control-Expose-Headers"];
35
- if (!exposedHeaders) return {
36
- ...headers,
37
- "Access-Control-Expose-Headers": headerName
38
- };
39
- const alreadyExposed = exposedHeaders.split(",").some((header) => header.trim().toLowerCase() === headerName.toLowerCase());
40
- return {
41
- ...headers,
42
- "Access-Control-Expose-Headers": alreadyExposed ? exposedHeaders : `${exposedHeaders}, ${headerName}`
43
- };
44
- }
45
- function make401Response(wwwAuth, corsHeaders) {
46
- return Response.json({
47
- jsonrpc: "2.0",
48
- error: {
49
- code: -32e3,
50
- message: "Unauthorized: Authentication required",
51
- "www-authenticate": wwwAuth
52
- },
53
- id: null
54
- }, {
55
- status: 401,
56
- headers: {
57
- ...addExposeHeader(corsHeaders, "WWW-Authenticate"),
58
- "WWW-Authenticate": wwwAuth
59
- }
60
- });
61
- }
62
- function send401Node(res, wwwAuth, message, corsHeaders) {
63
- const body = JSON.stringify({
64
- jsonrpc: "2.0",
65
- error: {
66
- code: -32e3,
67
- message
68
- },
69
- id: null
70
- });
71
- const existingExposedHeaders = res.get?.("Access-Control-Expose-Headers") ?? res.getHeader?.("Access-Control-Expose-Headers") ?? res.getHeader?.("access-control-expose-headers");
72
- const headers = {
73
- ...addExposeHeader({
74
- ...corsHeaders,
75
- ...existingExposedHeaders ? { "Access-Control-Expose-Headers": Array.isArray(existingExposedHeaders) ? existingExposedHeaders.join(", ") : String(existingExposedHeaders) } : {}
76
- }, "WWW-Authenticate"),
77
- "WWW-Authenticate": wwwAuth
78
- };
79
- if (typeof res.set === "function") {
80
- for (const [name, value] of Object.entries(headers)) res.set(name, value);
81
- res.status?.(401).json(JSON.parse(body));
82
- } else if (typeof res.writeHead === "function") {
83
- res.writeHead(401, {
84
- "Content-Type": "application/json",
85
- ...headers
86
- });
87
- res.end?.(body);
88
- }
89
- }
90
- function createMcpResourceClient(options) {
91
- const authURL = options.authURL.endsWith("/") ? options.authURL.slice(0, -1) : options.authURL;
92
- const fetchFn = options.fetch ?? globalThis.fetch;
93
- const corsHeaders = buildCorsHeaders(authURL, options.allowedOrigin);
94
- const expectedAudience = options.resource ?? authURL;
95
- const dpopReplayStore = options.dpop?.replayStore ?? createInMemoryDpopReplayStore();
96
- const dpopSigningAlgorithms = options.dpop?.signingAlgorithms ?? DPOP_SIGNING_ALGORITHMS;
97
- const selectChallenge = (authHeader, dpopHeaderPresent) => {
98
- return parseAccessTokenAuthorization(authHeader)?.scheme === "DPoP" || dpopHeaderPresent ? makeDpopWWWAuthenticate(dpopSigningAlgorithms) : makeWWWAuthenticate(authURL, options.resource);
99
- };
100
- let discovery = null;
101
- let jwks = null;
102
- const loadVerifier = async () => {
103
- if (discovery && jwks) return {
104
- discovery,
105
- jwks
106
- };
107
- const response = await fetchFn(`${authURL}/.well-known/oauth-authorization-server`);
108
- if (!response.ok) throw new Error("Failed to fetch discovery metadata");
109
- const metadata = await response.json();
110
- if (!metadata.jwks_uri || !metadata.issuer) throw new Error("Discovery metadata missing jwks_uri or issuer");
111
- discovery = {
112
- issuer: metadata.issuer,
113
- jwks_uri: metadata.jwks_uri
114
- };
115
- jwks = createRemoteJWKSet(new URL(metadata.jwks_uri));
116
- return {
117
- discovery,
118
- jwks
119
- };
120
- };
121
- const verifyJwtToken = async (token) => {
122
- try {
123
- const { discovery: meta, jwks: keySet } = await loadVerifier();
124
- const { payload } = await jwtVerify(token, keySet, {
125
- issuer: meta.issuer,
126
- audience: expectedAudience
127
- });
128
- return payload;
129
- } catch {
130
- return null;
131
- }
132
- };
133
- const verifyToken = async (token) => {
134
- const session = await verifyJwtToken(token);
135
- if (!session || getDpopJktFromPayload(session)) return null;
136
- return session;
137
- };
138
- /**
139
- * Verifies a request's access token and, when the token is DPoP-bound, its
140
- * RFC 9449 sender-constraint. Returns `null` when there is no usable token
141
- * or the JWT itself is invalid. Throws a `DpopBindingError` when a
142
- * DPoP-bound token fails the binding check, so the caller can answer with a
143
- * `WWW-Authenticate: DPoP` challenge rather than a bearer one.
144
- */
145
- const verifyRequest = async (req) => {
146
- const authorization = parseAccessTokenAuthorization(req.headers.get("Authorization"));
147
- if (!authorization?.token || authorization.scheme === "Unknown") return null;
148
- const session = await verifyJwtToken(authorization.token);
149
- if (!session) return null;
150
- await enforceDpopBinding({
151
- payload: session,
152
- authorization,
153
- proofJwt: req.headers.get("DPoP"),
154
- method: req.method,
155
- url: req.url,
156
- proofMaxAgeSeconds: options.dpop?.proofMaxAgeSeconds,
157
- signingAlgorithms: dpopSigningAlgorithms,
158
- replayStore: dpopReplayStore
159
- });
160
- return session;
161
- };
162
- const getHeader = (req, name) => {
163
- const lower = name.toLowerCase();
164
- const value = req.headers?.[lower] ?? req.headers?.[name] ?? req.headers?.get?.(name) ?? req.get?.(name);
165
- if (Array.isArray(value)) return value[0];
166
- return value;
167
- };
168
- const getNodeRequestUrl = (req) => {
169
- const rawUrl = req.originalUrl ?? req.url ?? "/";
170
- if (URL.canParse(rawUrl)) return rawUrl;
171
- const fallbackUrl = new URL(authURL);
172
- const host = getHeader(req, "host") ?? fallbackUrl.host;
173
- return `${getHeader(req, "x-forwarded-proto")?.split(",")[0]?.trim() ?? req.protocol ?? fallbackUrl.protocol.replace(":", "")}://${host}${rawUrl.startsWith("/") ? rawUrl : `/${rawUrl}`}`;
174
- };
175
- const handler = (fn) => {
176
- return async (req) => {
177
- if (req.method === "OPTIONS") return new Response(null, {
178
- status: 204,
179
- headers: corsHeaders
180
- });
181
- let session;
182
- try {
183
- session = await verifyRequest(req);
184
- } catch (error) {
185
- if (isDpopBindingError(error)) return make401Response(makeDpopWWWAuthenticate(dpopSigningAlgorithms), corsHeaders);
186
- throw error;
187
- }
188
- if (!session) return make401Response(selectChallenge(req.headers.get("Authorization"), req.headers.has("DPoP")), corsHeaders);
189
- return fn(req, session);
190
- };
191
- };
192
- const discoveryHandler = () => {
193
- let cachedMetadata = null;
194
- let cacheTime = 0;
195
- const CACHE_TTL = 6e4;
196
- return async (_req) => {
197
- const now = Date.now();
198
- if (cachedMetadata && now - cacheTime < CACHE_TTL) return Response.json(cachedMetadata, { headers: corsHeaders });
199
- try {
200
- const response = await fetchFn(`${authURL}/.well-known/oauth-authorization-server`);
201
- if (!response.ok) return Response.json({ error: "Failed to fetch discovery metadata" }, {
202
- status: 502,
203
- headers: corsHeaders
204
- });
205
- cachedMetadata = await response.json();
206
- cacheTime = now;
207
- return Response.json(cachedMetadata, { headers: corsHeaders });
208
- } catch {
209
- return Response.json({ error: "Better Auth server unreachable" }, {
210
- status: 502,
211
- headers: corsHeaders
212
- });
213
- }
214
- };
215
- };
216
- const protectedResourceHandler = (serverURL) => {
217
- const metadata = {
218
- resource: options.resource ?? new URL(serverURL).origin,
219
- authorization_servers: [authURL],
220
- bearer_methods_supported: ["header"],
221
- dpop_signing_alg_values_supported: [...dpopSigningAlgorithms]
222
- };
223
- return async (_req) => {
224
- return Response.json(metadata, { headers: corsHeaders });
225
- };
226
- };
227
- const middleware = () => {
228
- return async (req, res, next) => {
229
- const authHeader = getHeader(req, "authorization");
230
- const requestHeaders = new Headers();
231
- if (authHeader) requestHeaders.set("Authorization", authHeader);
232
- const dpop = getHeader(req, "dpop");
233
- if (dpop) requestHeaders.set("DPoP", dpop);
234
- const request = new Request(getNodeRequestUrl(req), {
235
- method: req.method ?? "GET",
236
- headers: requestHeaders
237
- });
238
- let session;
239
- try {
240
- session = await verifyRequest(request);
241
- } catch (error) {
242
- if (isDpopBindingError(error)) {
243
- send401Node(res, makeDpopWWWAuthenticate(dpopSigningAlgorithms), "Invalid or expired token", corsHeaders);
244
- return;
245
- }
246
- throw error;
247
- }
248
- if (!session) {
249
- send401Node(res, selectChallenge(authHeader, !!getHeader(req, "dpop")), authHeader ? "Invalid or expired token" : "Unauthorized: Authentication required", corsHeaders);
250
- return;
251
- }
252
- req.mcpSession = session;
253
- next();
254
- };
255
- };
256
- return {
257
- verifyToken,
258
- verifyRequest,
259
- handler,
260
- discoveryHandler,
261
- protectedResourceHandler,
262
- middleware,
263
- authURL
264
- };
265
- }
266
- //#endregion
267
- export { createMcpResourceClient, makeDpopWWWAuthenticate };