@softure-ai/mcp-access 0.2.1 → 0.2.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +41 -4
  3. package/dist/index.d.ts +4 -0
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +3 -3
  6. package/dist/index.js.map +1 -1
  7. package/dist/options.d.ts +8 -0
  8. package/dist/options.d.ts.map +1 -1
  9. package/dist/options.js +7 -0
  10. package/dist/options.js.map +1 -1
  11. package/dist/schema.d.ts +58 -0
  12. package/dist/schema.d.ts.map +1 -1
  13. package/dist/schema.js +9 -4
  14. package/dist/schema.js.map +1 -1
  15. package/dist/scripts/index.d.ts +2 -0
  16. package/dist/scripts/index.d.ts.map +1 -0
  17. package/dist/scripts/index.js +4 -0
  18. package/dist/scripts/index.js.map +1 -0
  19. package/dist/scripts/registration-scripts.d.ts +15 -0
  20. package/dist/scripts/registration-scripts.d.ts.map +1 -0
  21. package/dist/scripts/registration-scripts.js +73 -0
  22. package/dist/scripts/registration-scripts.js.map +1 -0
  23. package/dist/server/endpoint.d.ts.map +1 -1
  24. package/dist/server/endpoint.js +6 -2
  25. package/dist/server/endpoint.js.map +1 -1
  26. package/dist/server/health.d.ts.map +1 -1
  27. package/dist/server/health.js +3 -2
  28. package/dist/server/health.js.map +1 -1
  29. package/dist/server/index.d.ts +1 -0
  30. package/dist/server/index.d.ts.map +1 -1
  31. package/dist/server/index.js +1 -0
  32. package/dist/server/index.js.map +1 -1
  33. package/dist/server/oauth-http.d.ts +2 -0
  34. package/dist/server/oauth-http.d.ts.map +1 -1
  35. package/dist/server/oauth-http.js +28 -1
  36. package/dist/server/oauth-http.js.map +1 -1
  37. package/dist/server/registration-window.d.ts +20 -0
  38. package/dist/server/registration-window.d.ts.map +1 -0
  39. package/dist/server/registration-window.js +41 -0
  40. package/dist/server/registration-window.js.map +1 -0
  41. package/migrations/0003_create_oauth_registration_window.sql +11 -0
  42. package/module.json +3 -3
  43. package/package.json +10 -1
  44. package/src/index.ts +3 -3
  45. package/src/options.ts +7 -0
  46. package/src/schema.ts +10 -4
  47. package/src/scripts/index.ts +9 -0
  48. package/src/scripts/registration-scripts.ts +98 -0
  49. package/src/server/endpoint.ts +6 -2
  50. package/src/server/health.ts +3 -2
  51. package/src/server/index.ts +9 -0
  52. package/src/server/oauth-http.ts +26 -1
  53. package/src/server/registration-window.ts +55 -0
@@ -22,6 +22,7 @@ import {
22
22
  import { parseClientRegistration } from "./oauth-validation.js";
23
23
  import { getMcpAccessOptions, getMcpAccessRoutes, getMcpEndpointUrl } from "./options.js";
24
24
  import { resolveMcpOrigins } from "./origins.js";
25
+ import { isClientRegistrationOpen } from "./registration-window.js";
25
26
  import { isValidCodeChallenge, PKCE_METHOD } from "./pkce.js";
26
27
  import type { McpAccessContext } from "./tokens.js";
27
28
 
@@ -172,6 +173,15 @@ export function isWriteScopeRequested(scope: string | null): boolean {
172
173
  return (scope ?? "").split(/\s+/).includes(MCP_WRITE_SCOPE);
173
174
  }
174
175
 
176
+ /**
177
+ * Whether the request asks to write, where a missing or blank `scope` is the server's default
178
+ * (RFC 6749 §3.3): both scopes while the app allows writes, `mcp:read` otherwise.
179
+ */
180
+ function isWriteRequestedOrDefault(config: SoftureConfig, scope: string | null): boolean {
181
+ if (scope === null || scope.trim() === "") return getMcpAccessOptions(config).allowWrites;
182
+ return isWriteScopeRequested(scope);
183
+ }
184
+
175
185
  // ---------------------------------------------------------------------------------------------
176
186
  // Rate limit
177
187
 
@@ -221,14 +231,29 @@ function bodyTooLarge(config: SoftureConfig): Response {
221
231
  // ---------------------------------------------------------------------------------------------
222
232
  // Registration
223
233
 
234
+ /** The answer while registration is closed, or null when it is open. A failing read closes (503). */
235
+ async function refuseClosedRegistration(ctx: McpAccessContext): Promise<Response | null> {
236
+ try {
237
+ if (await isClientRegistrationOpen(ctx)) return null;
238
+ } catch (error) {
239
+ console.error(`@softure-ai/mcp-access: reading the client registration window failed: ${errorLogLabel(error)}`);
240
+ return protocolError("temporarily_unavailable", "The server is temporarily unavailable.", 503);
241
+ }
242
+ return protocolError("access_denied", "Client registration is closed.", 403);
243
+ }
244
+
224
245
  /**
225
246
  * `POST` dynamic client registration (RFC 7591). Public by definition: claude.ai and ChatGPT
226
247
  * register before anyone signs in. A registration grants nothing; access needs a person's consent.
248
+ * With `oauth.registration: "closed"` it answers `403 access_denied` outside the window the
249
+ * `open-client-registration` ops script opens; the request still counts in the rate limit.
227
250
  */
228
251
  export async function handleClientRegistration(ctx: McpAccessContext, request: Request): Promise<Response> {
229
252
  if (!isOAuthEnabled(ctx.config)) return protocolError("not_found", "OAuth is not enabled.", 404);
230
253
  const limited = await limitProtocolRequest(ctx, request);
231
254
  if (limited !== null) return limited;
255
+ const closed = await refuseClosedRegistration(ctx);
256
+ if (closed !== null) return closed;
232
257
 
233
258
  const notJson = () => protocolError("invalid_client_metadata", "The request body must be a JSON object.");
234
259
  const text = await readCappedBody(ctx.config, request);
@@ -442,7 +467,7 @@ export async function validateAuthorizationRequest(
442
467
  return back("invalid_request", "PKCE is required: code_challenge with code_challenge_method S256.");
443
468
  }
444
469
  if (!isAcceptableResource(ctx.config, params.get("resource"), origins)) return back("invalid_target", "The resource parameter does not name this MCP server.");
445
- return { kind: "valid", request: { client, redirectUri, state, codeChallenge, isWriteRequested: isWriteScopeRequested(params.get("scope")) } };
470
+ return { kind: "valid", request: { client, redirectUri, state, codeChallenge, isWriteRequested: isWriteRequestedOrDefault(ctx.config, params.get("scope")) } };
446
471
  }
447
472
 
448
473
  /** The consent page with the request's parameters: where the decision sends the person back. */
@@ -0,0 +1,55 @@
1
+ // The window in which dynamic client registration is open while `oauth.registration` is "closed"
2
+ // (#520): one stored row with its end, written by the `open-client-registration` ops script. Every
3
+ // comparison uses the context's clock, so tests and scripts agree with the route.
4
+ import { oauthRegistrationWindow } from "../schema.js";
5
+ import { getMcpAccessOptions } from "./options.js";
6
+ import type { McpAccessContext } from "./tokens.js";
7
+
8
+ /** The longest window one call opens: a day. */
9
+ export const MAX_REGISTRATION_WINDOW_MINUTES = 1440;
10
+
11
+ export interface ClientRegistrationWindow {
12
+ readonly openedAt: Date;
13
+ readonly openUntil: Date;
14
+ }
15
+
16
+ export interface OpenClientRegistrationInput {
17
+ /** Minutes from now, 1 to 1440. */
18
+ readonly minutes: number;
19
+ }
20
+
21
+ /** The stored window, past or not; null when none is stored. */
22
+ export async function findClientRegistrationWindow(ctx: McpAccessContext): Promise<ClientRegistrationWindow | null> {
23
+ const [row] = await ctx.db
24
+ .select({ openedAt: oauthRegistrationWindow.openedAt, openUntil: oauthRegistrationWindow.openUntil })
25
+ .from(oauthRegistrationWindow)
26
+ .limit(1);
27
+ return row ?? null;
28
+ }
29
+
30
+ /** True with `oauth.registration: "open"`; with "closed", only while a stored window has not ended. */
31
+ export async function isClientRegistrationOpen(ctx: McpAccessContext): Promise<boolean> {
32
+ if (getMcpAccessOptions(ctx.config).oauth.registration === "open") return true;
33
+ const window = await findClientRegistrationWindow(ctx);
34
+ return window !== null && window.openUntil.getTime() > ctx.clock.now().getTime();
35
+ }
36
+
37
+ /** Opens registration for `minutes` from now, replacing any stored window. Throws on minutes outside 1 to 1440: a bug in the caller. */
38
+ export async function openClientRegistration(ctx: McpAccessContext, input: OpenClientRegistrationInput): Promise<ClientRegistrationWindow> {
39
+ if (!Number.isInteger(input.minutes) || input.minutes < 1 || input.minutes > MAX_REGISTRATION_WINDOW_MINUTES) {
40
+ throw new Error(`openClientRegistration: minutes must be a whole number from 1 to ${String(MAX_REGISTRATION_WINDOW_MINUTES)}, got ${String(input.minutes)}`);
41
+ }
42
+ const openedAt = ctx.clock.now();
43
+ const openUntil = new Date(openedAt.getTime() + input.minutes * 60_000);
44
+ await ctx.db
45
+ .insert(oauthRegistrationWindow)
46
+ .values({ id: 1, openedAt, openUntil })
47
+ .onConflictDoUpdate({ target: oauthRegistrationWindow.id, set: { openedAt, openUntil } });
48
+ return { openedAt, openUntil };
49
+ }
50
+
51
+ /** Deletes the stored window. True when one was stored, ended or not. */
52
+ export async function closeClientRegistration(ctx: McpAccessContext): Promise<boolean> {
53
+ const deleted = await ctx.db.delete(oauthRegistrationWindow).returning({ id: oauthRegistrationWindow.id });
54
+ return deleted.length > 0;
55
+ }