@softure-ai/mcp-access 0.1.6 → 0.1.7

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 (128) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +110 -21
  3. package/adoption/move-app-tables.sql +100 -0
  4. package/dist/contract.d.ts +13 -1
  5. package/dist/contract.d.ts.map +1 -1
  6. package/dist/index.d.ts +51 -3
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +25 -5
  9. package/dist/index.js.map +1 -1
  10. package/dist/messages/en.d.ts +28 -0
  11. package/dist/messages/en.d.ts.map +1 -1
  12. package/dist/messages/en.js +28 -0
  13. package/dist/messages/en.js.map +1 -1
  14. package/dist/messages/index.d.ts +56 -0
  15. package/dist/messages/index.d.ts.map +1 -1
  16. package/dist/messages/pl.d.ts.map +1 -1
  17. package/dist/messages/pl.js +28 -0
  18. package/dist/messages/pl.js.map +1 -1
  19. package/dist/next/actions.d.ts +3 -1
  20. package/dist/next/actions.d.ts.map +1 -1
  21. package/dist/next/actions.js +19 -0
  22. package/dist/next/actions.js.map +1 -1
  23. package/dist/next/index.d.ts +3 -1
  24. package/dist/next/index.d.ts.map +1 -1
  25. package/dist/next/index.js +4 -2
  26. package/dist/next/index.js.map +1 -1
  27. package/dist/next/oauth-page.d.ts +7 -0
  28. package/dist/next/oauth-page.d.ts.map +1 -0
  29. package/dist/next/oauth-page.js +35 -0
  30. package/dist/next/oauth-page.js.map +1 -0
  31. package/dist/next/oauth-routes.d.ts +16 -0
  32. package/dist/next/oauth-routes.d.ts.map +1 -0
  33. package/dist/next/oauth-routes.js +50 -0
  34. package/dist/next/oauth-routes.js.map +1 -0
  35. package/dist/next/pages.d.ts.map +1 -1
  36. package/dist/next/pages.js +13 -2
  37. package/dist/next/pages.js.map +1 -1
  38. package/dist/options.d.ts +16 -0
  39. package/dist/options.d.ts.map +1 -1
  40. package/dist/options.js +28 -1
  41. package/dist/options.js.map +1 -1
  42. package/dist/schema.d.ts +486 -0
  43. package/dist/schema.d.ts.map +1 -1
  44. package/dist/schema.js +45 -3
  45. package/dist/schema.js.map +1 -1
  46. package/dist/server/endpoint.d.ts +2 -4
  47. package/dist/server/endpoint.d.ts.map +1 -1
  48. package/dist/server/endpoint.js +10 -7
  49. package/dist/server/endpoint.js.map +1 -1
  50. package/dist/server/health.d.ts.map +1 -1
  51. package/dist/server/health.js +5 -3
  52. package/dist/server/health.js.map +1 -1
  53. package/dist/server/index.d.ts +4 -0
  54. package/dist/server/index.d.ts.map +1 -1
  55. package/dist/server/index.js +4 -0
  56. package/dist/server/index.js.map +1 -1
  57. package/dist/server/oauth-http.d.ts +113 -0
  58. package/dist/server/oauth-http.d.ts.map +1 -0
  59. package/dist/server/oauth-http.js +374 -0
  60. package/dist/server/oauth-http.js.map +1 -0
  61. package/dist/server/oauth-validation.d.ts +28 -0
  62. package/dist/server/oauth-validation.d.ts.map +1 -0
  63. package/dist/server/oauth-validation.js +88 -0
  64. package/dist/server/oauth-validation.js.map +1 -0
  65. package/dist/server/oauth.d.ts +106 -0
  66. package/dist/server/oauth.d.ts.map +1 -0
  67. package/dist/server/oauth.js +235 -0
  68. package/dist/server/oauth.js.map +1 -0
  69. package/dist/server/options.d.ts +8 -0
  70. package/dist/server/options.d.ts.map +1 -1
  71. package/dist/server/options.js +8 -1
  72. package/dist/server/options.js.map +1 -1
  73. package/dist/server/pkce.d.ts +7 -0
  74. package/dist/server/pkce.d.ts.map +1 -0
  75. package/dist/server/pkce.js +24 -0
  76. package/dist/server/pkce.js.map +1 -0
  77. package/dist/server/privacy.d.ts +7 -0
  78. package/dist/server/privacy.d.ts.map +1 -1
  79. package/dist/server/privacy.js +19 -9
  80. package/dist/server/privacy.js.map +1 -1
  81. package/dist/server/scopes.d.ts +5 -0
  82. package/dist/server/scopes.d.ts.map +1 -0
  83. package/dist/server/scopes.js +6 -0
  84. package/dist/server/scopes.js.map +1 -0
  85. package/dist/server/tokens.d.ts +18 -6
  86. package/dist/server/tokens.d.ts.map +1 -1
  87. package/dist/server/tokens.js +60 -25
  88. package/dist/server/tokens.js.map +1 -1
  89. package/dist/ui/consent-form.d.ts +31 -0
  90. package/dist/ui/consent-form.d.ts.map +1 -0
  91. package/dist/ui/consent-form.js +24 -0
  92. package/dist/ui/consent-form.js.map +1 -0
  93. package/dist/ui/index.d.ts +2 -1
  94. package/dist/ui/index.d.ts.map +1 -1
  95. package/dist/ui/index.js +1 -0
  96. package/dist/ui/index.js.map +1 -1
  97. package/dist/ui/token-manager.d.ts +16 -2
  98. package/dist/ui/token-manager.d.ts.map +1 -1
  99. package/dist/ui/token-manager.js +6 -2
  100. package/dist/ui/token-manager.js.map +1 -1
  101. package/migrations/0002_create_oauth_grants.sql +68 -0
  102. package/module.json +18 -4
  103. package/package.json +2 -1
  104. package/src/contract.ts +16 -1
  105. package/src/index.ts +28 -4
  106. package/src/messages/en.ts +28 -0
  107. package/src/messages/pl.ts +28 -0
  108. package/src/next/actions.ts +19 -1
  109. package/src/next/index.ts +11 -2
  110. package/src/next/oauth-page.tsx +67 -0
  111. package/src/next/oauth-routes.ts +65 -0
  112. package/src/next/pages.tsx +16 -3
  113. package/src/options.ts +32 -1
  114. package/src/schema.ts +51 -3
  115. package/src/server/endpoint.ts +10 -7
  116. package/src/server/health.ts +5 -3
  117. package/src/server/index.ts +50 -0
  118. package/src/server/oauth-http.ts +431 -0
  119. package/src/server/oauth-validation.ts +106 -0
  120. package/src/server/oauth.ts +315 -0
  121. package/src/server/options.ts +16 -1
  122. package/src/server/pkce.ts +27 -0
  123. package/src/server/privacy.ts +26 -9
  124. package/src/server/scopes.ts +6 -0
  125. package/src/server/tokens.ts +62 -25
  126. package/src/ui/consent-form.tsx +103 -0
  127. package/src/ui/index.ts +3 -0
  128. package/src/ui/token-manager.tsx +79 -3
package/module.json CHANGED
@@ -1,15 +1,29 @@
1
1
  {
2
2
  "id": "mcp-access",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "dependsOn": { "security": "^0.1.0", "auth": "^0.1.0" },
5
5
  "dbSchema": "mcp",
6
- "tables": ["access_tokens"],
6
+ "tables": ["access_tokens", "oauth_clients", "oauth_authorization_codes", "oauth_grants"],
7
7
  "env": [],
8
8
  "switches": [],
9
- "routes": { "page": "/account/mcp", "endpoint": "/api/mcp" },
9
+ "routes": {
10
+ "page": "/account/mcp",
11
+ "endpoint": "/api/mcp",
12
+ "oauthConsent": "/oauth/authorize",
13
+ "oauthDecision": "/api/oauth/authorize",
14
+ "oauthToken": "/api/oauth/token",
15
+ "oauthRegister": "/api/oauth/register"
16
+ },
10
17
  "mount": [
11
18
  { "kind": "page", "path": "app/account/mcp/page.tsx", "export": "McpAccessPage" },
12
- { "kind": "route-handler", "path": "app/api/mcp/route.ts", "export": "createMcpRoute" }
19
+ { "kind": "route-handler", "path": "app/api/mcp/route.ts", "export": "createMcpRoute" },
20
+ { "kind": "page", "path": "app/oauth/authorize/page.tsx", "export": "OAuthConsentPage" },
21
+ { "kind": "route-handler", "path": "app/api/oauth/authorize/route.ts", "export": "decideOAuthAuthorizationRoute" },
22
+ { "kind": "route-handler", "path": "app/api/oauth/token/route.ts", "export": "exchangeOAuthTokenRoute" },
23
+ { "kind": "route-handler", "path": "app/api/oauth/register/route.ts", "export": "registerOAuthClientRoute" },
24
+ { "kind": "route-handler", "path": "app/.well-known/oauth-authorization-server/route.ts", "export": "getAuthorizationServerMetadataRoute" },
25
+ { "kind": "route-handler", "path": "app/.well-known/oauth-protected-resource/route.ts", "export": "getProtectedResourceMetadataRoute" },
26
+ { "kind": "route-handler", "path": "app/.well-known/oauth-protected-resource/api/mcp/route.ts", "export": "getProtectedResourceMetadataRoute" }
13
27
  ],
14
28
  "privacy": { "exports": true, "deletes": true }
15
29
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softure-ai/mcp-access",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "SOFTURE AI MCP access module: hashed, scoped, expiring access tokens, a rate-limited Bearer endpoint around the app's MCP server factory, and a token page with client setup instructions for Next.js.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -20,6 +20,7 @@
20
20
  "!src/**/*.test.ts",
21
21
  "!src/**/*.test.tsx",
22
22
  "migrations",
23
+ "adoption",
23
24
  "module.json"
24
25
  ],
25
26
  "publishConfig": {
package/src/contract.ts CHANGED
@@ -6,7 +6,8 @@ export type McpAccessErrorCode =
6
6
  | "mcp-access.name_required"
7
7
  | "mcp-access.name_too_long"
8
8
  | "mcp-access.token_limit_reached"
9
- | "mcp-access.token_not_found";
9
+ | "mcp-access.token_not_found"
10
+ | "mcp-access.grant_not_found";
10
11
 
11
12
  /** Every code the token page can show: its own, a missing session and the generic ones. */
12
13
  export type TokenFormErrorCode = McpAccessErrorCode | "auth.unauthenticated" | CoreErrorCode;
@@ -73,8 +74,22 @@ export type IssueTokenFormState =
73
74
  | { readonly status: "ok"; readonly issued: IssuedToken }
74
75
  | { readonly status: "error"; readonly error: TokenFormErrorCode };
75
76
 
77
+ /** A connected app (an OAuth grant) as its owner sees it. */
78
+ export interface OAuthGrantView {
79
+ readonly id: string;
80
+ /** The name the client registered with, cut to 60 characters. */
81
+ readonly clientName: string;
82
+ /** Granted with write access (it still writes only while the app allows writes). */
83
+ readonly canWrite: boolean;
84
+ readonly createdAt: Date;
85
+ readonly lastUsedAt: Date | null;
86
+ }
87
+
76
88
  /** What the revoke action returns to one token's form. */
77
89
  export type RevokeTokenFormState =
78
90
  | { readonly status: "idle" }
79
91
  | { readonly status: "ok" }
80
92
  | { readonly status: "error"; readonly error: TokenFormErrorCode };
93
+
94
+ /** What the disconnect action returns to one connected app's form. */
95
+ export type RevokeGrantFormState = RevokeTokenFormState;
package/src/index.ts CHANGED
@@ -17,6 +17,12 @@ export const MODULE_ID = "mcp-access";
17
17
  */
18
18
  export const MCP_RATE_LIMIT_BUCKETS = {
19
19
  mcp: { limit: 200, windowMinutes: 15 },
20
+ /**
21
+ * OAuth registration (per address) and token requests (per address and client id), when
22
+ * `oauth.enabled`. Enough for an assistant platform refreshing many users' tokens per client,
23
+ * too little for a script filling the clients table.
24
+ */
25
+ "mcp-oauth": { limit: 60, windowMinutes: 15 },
20
26
  } as const;
21
27
 
22
28
  /**
@@ -26,16 +32,30 @@ export const MCP_RATE_LIMIT_BUCKETS = {
26
32
  export const mcpAccess = defineModule({
27
33
  manifest: {
28
34
  id: MODULE_ID,
29
- version: "0.1.6",
35
+ version: "0.1.7",
30
36
  dependsOn: { security: "^0.1.0", auth: "^0.1.0" },
31
37
  dbSchema: "mcp",
32
- tables: ["access_tokens"],
38
+ tables: ["access_tokens", "oauth_clients", "oauth_authorization_codes", "oauth_grants"],
33
39
  env: [],
34
40
  switches: [],
35
- routes: { page: "/account/mcp", endpoint: "/api/mcp" },
41
+ routes: {
42
+ page: "/account/mcp",
43
+ endpoint: "/api/mcp",
44
+ oauthConsent: "/oauth/authorize",
45
+ oauthDecision: "/api/oauth/authorize",
46
+ oauthToken: "/api/oauth/token",
47
+ oauthRegister: "/api/oauth/register",
48
+ },
36
49
  mount: [
37
50
  { kind: "page", path: "app/account/mcp/page.tsx", export: "McpAccessPage" },
38
51
  { kind: "route-handler", path: "app/api/mcp/route.ts", export: "createMcpRoute" },
52
+ { kind: "page", path: "app/oauth/authorize/page.tsx", export: "OAuthConsentPage" },
53
+ { kind: "route-handler", path: "app/api/oauth/authorize/route.ts", export: "decideOAuthAuthorizationRoute" },
54
+ { kind: "route-handler", path: "app/api/oauth/token/route.ts", export: "exchangeOAuthTokenRoute" },
55
+ { kind: "route-handler", path: "app/api/oauth/register/route.ts", export: "registerOAuthClientRoute" },
56
+ { kind: "route-handler", path: "app/.well-known/oauth-authorization-server/route.ts", export: "getAuthorizationServerMetadataRoute" },
57
+ { kind: "route-handler", path: "app/.well-known/oauth-protected-resource/route.ts", export: "getProtectedResourceMetadataRoute" },
58
+ { kind: "route-handler", path: "app/.well-known/oauth-protected-resource/api/mcp/route.ts", export: "getProtectedResourceMetadataRoute" },
39
59
  ],
40
60
  privacy: { exports: true, deletes: true },
41
61
  },
@@ -63,19 +83,23 @@ export type {
63
83
  McpAccessErrorCode,
64
84
  McpClientSetup,
65
85
  McpServerIdentity,
86
+ OAuthGrantView,
87
+ RevokeGrantFormState,
66
88
  RevokeTokenFormState,
67
89
  TokenFormErrorCode,
68
90
  } from "./contract.js";
69
91
  export { getTokenErrorMessage, mcpAccessMessages, type McpAccessMessages } from "./messages/index.js";
70
92
  export {
93
+ MAX_PRESENTED_TOKEN_LENGTH,
71
94
  MAX_TOKEN_NAME_LENGTH,
72
95
  SERVER_NAME_PATTERN,
73
96
  TOOL_NAME_PATTERN,
74
97
  type McpAccessOptions,
75
98
  type McpAccessOptionsInput,
99
+ type McpOAuthOptions,
76
100
  type McpToolAccess,
77
101
  type McpToolDefinition,
78
102
  type McpToolDefinitionInput,
79
103
  } from "./options.js";
80
- export { accessTokens } from "./schema.js";
104
+ export { accessTokens, oauthAuthorizationCodes, oauthClients, oauthGrants } from "./schema.js";
81
105
  export { getAccessTokenStatus, type AccessTokenStatusOptions } from "./token-status.js";
@@ -58,12 +58,40 @@ export const en = {
58
58
  revoke: "Revoke",
59
59
  revoking: "Revoking…",
60
60
  },
61
+ grants: {
62
+ title: "Connected apps",
63
+ lead: "Assistants you connected by signing in, such as claude.ai or ChatGPT. Disconnecting one stops its access at once.",
64
+ empty: "No app is connected.",
65
+ connectedOn: "Connected {date}",
66
+ disconnect: "Disconnect",
67
+ disconnecting: "Disconnecting…",
68
+ },
69
+ consent: {
70
+ title: "{client} wants access to your account",
71
+ lead: "The assistant can answer questions with your data in this app. You can disconnect it at any time on the AI assistant access page.",
72
+ app: "App",
73
+ returnsTo: "returns to {target}",
74
+ account: "Account",
75
+ access: "Access",
76
+ readAccess: "Read your data",
77
+ allowWrite: "Also allow changes",
78
+ allowWriteHint: "Without it, the assistant can only read.",
79
+ allow: "Allow",
80
+ deny: "Deny",
81
+ errorTitle: "The assistant cannot be connected",
82
+ unknownClient: "The app that sent you here is not registered. Connect it again from the assistant.",
83
+ redirectMismatch: "The return address does not match the one the app registered. Connect it again from the assistant.",
84
+ invalidRequest: "The app sent a connection request this app cannot accept.",
85
+ nothingGranted: "No access was granted.",
86
+ backToApp: "Back to the app ({target})",
87
+ },
61
88
  errors: {
62
89
  "mcp-access": {
63
90
  name_required: "Give the token a name, so you can tell it from the others.",
64
91
  name_too_long: "The name is too long.",
65
92
  token_limit_reached: "You already hold the most active tokens allowed. Revoke one before creating another.",
66
93
  token_not_found: "This token no longer exists. It may have been revoked already.",
94
+ grant_not_found: "This app is no longer connected.",
67
95
  },
68
96
  auth: {
69
97
  unauthenticated: "Your session has ended. Log in again.",
@@ -60,12 +60,40 @@ export const pl: typeof en = {
60
60
  revoke: "Unieważnij",
61
61
  revoking: "Unieważnianie…",
62
62
  },
63
+ grants: {
64
+ title: "Połączone aplikacje",
65
+ lead: "Asystenci połączeni przez logowanie, na przykład claude.ai albo ChatGPT. Odłączenie od razu odbiera im dostęp.",
66
+ empty: "Żadna aplikacja nie jest połączona.",
67
+ connectedOn: "Połączono {date}",
68
+ disconnect: "Odłącz",
69
+ disconnecting: "Odłączanie…",
70
+ },
71
+ consent: {
72
+ title: "{client} prosi o dostęp do Twojego konta",
73
+ lead: "Asystent odpowie na pytania, korzystając z Twoich danych w tej aplikacji. Możesz go odłączyć w każdej chwili na stronie dostępu dla asystenta AI.",
74
+ app: "Aplikacja",
75
+ returnsTo: "wróci na {target}",
76
+ account: "Konto",
77
+ access: "Dostęp",
78
+ readAccess: "Odczyt Twoich danych",
79
+ allowWrite: "Pozwól też na zmiany",
80
+ allowWriteHint: "Bez tego asystent może tylko czytać.",
81
+ allow: "Zezwól",
82
+ deny: "Odmów",
83
+ errorTitle: "Nie da się połączyć asystenta",
84
+ unknownClient: "Aplikacja, która Cię tu przysłała, nie jest zarejestrowana. Połącz ją od nowa z poziomu asystenta.",
85
+ redirectMismatch: "Adres powrotu nie zgadza się z tym, który aplikacja zarejestrowała. Połącz ją od nowa z poziomu asystenta.",
86
+ invalidRequest: "Aplikacja wysłała żądanie połączenia, którego ta aplikacja nie może przyjąć.",
87
+ nothingGranted: "Nie nadano żadnego dostępu.",
88
+ backToApp: "Wróć do aplikacji ({target})",
89
+ },
63
90
  errors: {
64
91
  "mcp-access": {
65
92
  name_required: "Nadaj tokenowi nazwę, żeby odróżnić go od pozostałych.",
66
93
  name_too_long: "Nazwa jest za długa.",
67
94
  token_limit_reached: "Masz już najwięcej aktywnych tokenów, ile można. Unieważnij któryś, zanim utworzysz nowy.",
68
95
  token_not_found: "Ten token już nie istnieje. Mógł zostać unieważniony.",
96
+ grant_not_found: "Ta aplikacja nie jest już połączona.",
69
97
  },
70
98
  auth: {
71
99
  unauthenticated: "Twoja sesja wygasła. Zaloguj się ponownie.",
@@ -9,9 +9,10 @@ import { getSoftureConfig } from "@softure-ai/core/next";
9
9
  import { revalidatePath } from "next/cache";
10
10
  import { z } from "zod";
11
11
  import { getMcpClientSetup } from "../client-setup.js";
12
- import type { IssueTokenFormState, RevokeTokenFormState } from "../contract.js";
12
+ import type { IssueTokenFormState, RevokeGrantFormState, RevokeTokenFormState } from "../contract.js";
13
13
  import { MAX_TOKEN_NAME_LENGTH } from "../options.js";
14
14
  import { getMcpAccessMessages, getMcpAccessOptions, getMcpAccessRoutes, getMcpEndpointUrl } from "../server/options.js";
15
+ import { revokeOAuthGrant } from "../server/oauth.js";
15
16
  import { issueAccessToken, revokeAccessToken } from "../server/tokens.js";
16
17
  import { getMcpAccessContext } from "./context.js";
17
18
  import { formatDate } from "./format.js";
@@ -89,3 +90,20 @@ export async function revokeTokenAction(_previous: RevokeTokenFormState, formDat
89
90
  return { status: "error", error: reportFailure("revoking a token", error) };
90
91
  }
91
92
  }
93
+
94
+ /** Disconnects one of the signed-in user's OAuth apps: the grant and its access tokens go at once. */
95
+ export async function revokeGrantAction(_previous: RevokeGrantFormState, formData: FormData): Promise<RevokeGrantFormState> {
96
+ const config = getSoftureConfig();
97
+ const user = await getCurrentUser();
98
+ if (user === null) return { status: "error", error: "auth.unauthenticated" };
99
+
100
+ const input = revokeInput.parse({ id: formData.get("id") });
101
+ try {
102
+ const result = await revokeOAuthGrant(await getMcpAccessContext(config), { userId: user.id, grantId: input.id });
103
+ if (!result.ok) return { status: "error", error: result.error };
104
+ refreshPage(config);
105
+ return { status: "ok" };
106
+ } catch (error) {
107
+ return { status: "error", error: reportFailure("disconnecting an app", error) };
108
+ }
109
+ }
package/src/next/index.ts CHANGED
@@ -1,5 +1,14 @@
1
1
  // The Next.js adapter of @softure-ai/mcp-access: the MCP route factory, the token page and its
2
- // server actions (docs/02-module-standard.md §8).
3
- export { issueTokenAction, revokeTokenAction } from "./actions.js";
2
+ // server actions, and the OAuth routes and consent page (docs/02-module-standard.md §8).
3
+ export { issueTokenAction, revokeGrantAction, revokeTokenAction } from "./actions.js";
4
+ export { OAuthConsentPage, type OAuthConsentPageProps } from "./oauth-page.js";
5
+ export {
6
+ answerOAuthPreflight,
7
+ decideOAuthAuthorizationRoute,
8
+ exchangeOAuthTokenRoute,
9
+ getAuthorizationServerMetadataRoute,
10
+ getProtectedResourceMetadataRoute,
11
+ registerOAuthClientRoute,
12
+ } from "./oauth-routes.js";
4
13
  export { McpAccessPage } from "./pages.js";
5
14
  export { createMcpRoute } from "./route.js";
@@ -0,0 +1,67 @@
1
+ // The OAuth consent page, ready to mount with one line:
2
+ // `export { OAuthConsentPage as default } from "@softure-ai/mcp-access/next"`.
3
+ // The authorization endpoint clients send the person to. Without a session it redirects to the
4
+ // login page and back with every parameter (state, challenge, resource). Errors before the client
5
+ // and its redirect URI are confirmed are shown here, never redirected; later ones offer a link back
6
+ // to the client, never an automatic redirect (anyone can register a redirect URI).
7
+ import { requireUser } from "@softure-ai/auth/next";
8
+ import { formatMessage } from "@softure-ai/core";
9
+ import { getSoftureConfig } from "@softure-ai/core/next";
10
+ import { Card } from "@softure-ai/ui";
11
+ import { notFound } from "next/navigation";
12
+ import { getConsentPath, isOAuthEnabled, readAuthorizationParams, validateAuthorizationRequest } from "../server/oauth-http.js";
13
+ import { describeRedirectUri } from "../server/oauth-validation.js";
14
+ import { getMcpAccessMessages, getMcpAccessOptions, getMcpAccessRoutes } from "../server/options.js";
15
+ import { ConsentError, ConsentForm } from "../ui/consent-form.js";
16
+ import { getMcpAccessContext } from "./context.js";
17
+
18
+ type SearchParams = Promise<Record<string, string | string[] | undefined>>;
19
+
20
+ export interface OAuthConsentPageProps {
21
+ readonly searchParams?: SearchParams;
22
+ }
23
+
24
+ const LAYOUT_CLASS = "sft:mx-auto sft:box-border sft:w-full sft:sm:max-w-md sft:px-4 sft:py-4";
25
+
26
+ export async function OAuthConsentPage({ searchParams }: OAuthConsentPageProps) {
27
+ const config = getSoftureConfig();
28
+ if (!isOAuthEnabled(config)) notFound();
29
+ const query = (await searchParams) ?? {};
30
+ const params = readAuthorizationParams({ get: (name: string) => query[name] });
31
+ const user = await requireUser({ next: getConsentPath(config, params), searchParams: query });
32
+
33
+ const messages = getMcpAccessMessages(config);
34
+ const outcome = await validateAuthorizationRequest(await getMcpAccessContext(config), params);
35
+ if (outcome.kind !== "valid") {
36
+ const message =
37
+ outcome.kind === "redirect-error" ? messages.consent.invalidRequest : outcome.reason === "unknown_client" ? messages.consent.unknownClient : messages.consent.redirectMismatch;
38
+ return (
39
+ <main className={LAYOUT_CLASS}>
40
+ <Card title={messages.consent.errorTitle}>
41
+ <ConsentError
42
+ message={message}
43
+ {...(outcome.kind === "redirect-error" ? { backLocation: outcome.location, backTarget: describeRedirectUri(outcome.redirectUri) } : {})}
44
+ messages={messages}
45
+ />
46
+ </Card>
47
+ </main>
48
+ );
49
+ }
50
+
51
+ const { client, redirectUri, isWriteRequested } = outcome.request;
52
+ return (
53
+ <main className={LAYOUT_CLASS}>
54
+ <Card title={formatMessage(messages.consent.title, { client: client.clientName })} subtitle={messages.consent.lead}>
55
+ <ConsentForm
56
+ action={getMcpAccessRoutes(config).oauthDecision}
57
+ params={[...params.entries()]}
58
+ clientName={client.clientName}
59
+ redirectTarget={describeRedirectUri(redirectUri)}
60
+ accountEmail={user.email}
61
+ isWriteOffered={isWriteRequested && getMcpAccessOptions(config).allowWrites}
62
+ messages={messages}
63
+ />
64
+ </Card>
65
+ </main>
66
+ );
67
+ }
@@ -0,0 +1,65 @@
1
+ // The OAuth routes, each mounted with one line (README §4), e.g.
2
+ // `app/api/oauth/token/route.ts → export { exchangeOAuthTokenRoute as POST, answerOAuthPreflight as OPTIONS } from "@softure-ai/mcp-access/next";`
3
+ // They supply the module context (and, for the decision, the session's user) to the handlers in
4
+ // `/server`. With `oauth.enabled` off every one answers 404.
5
+ import { getCurrentUser } from "@softure-ai/auth/next";
6
+ import { errorLogLabel } from "@softure-ai/core";
7
+ import { getSoftureConfig } from "@softure-ai/core/next";
8
+ import {
9
+ answerOAuthPreflight,
10
+ getAuthorizationServerMetadata,
11
+ getProtectedResourceMetadata,
12
+ handleAuthorizationDecision,
13
+ handleClientRegistration,
14
+ handleTokenRequest,
15
+ serveDiscoveryDocument,
16
+ } from "../server/oauth-http.js";
17
+ import type { McpAccessContext } from "../server/tokens.js";
18
+ import { getMcpAccessContext } from "./context.js";
19
+
20
+ const UNAVAILABLE = { status: 503, headers: { "cache-control": "no-store" } };
21
+
22
+ async function withContext(request: Request, handle: (ctx: McpAccessContext, request: Request) => Promise<Response>): Promise<Response> {
23
+ let ctx;
24
+ try {
25
+ ctx = await getMcpAccessContext();
26
+ } catch (error) {
27
+ console.error(`@softure-ai/mcp-access: opening the database failed: ${errorLogLabel(error)}`);
28
+ return Response.json({ error: "temporarily_unavailable" }, UNAVAILABLE);
29
+ }
30
+ return handle(ctx, request);
31
+ }
32
+
33
+ /** `GET /.well-known/oauth-authorization-server`. */
34
+ export function getAuthorizationServerMetadataRoute(): Response {
35
+ return serveDiscoveryDocument(getSoftureConfig(), getAuthorizationServerMetadata);
36
+ }
37
+
38
+ /**
39
+ * `GET /.well-known/oauth-protected-resource` and `…/oauth-protected-resource/api/mcp`: the same
40
+ * export at both paths; the request's path picks the variant.
41
+ */
42
+ export function getProtectedResourceMetadataRoute(request: Request): Response {
43
+ const variant = new URL(request.url).pathname.replace(/\/+$/, "").endsWith("/oauth-protected-resource") ? "root" : "endpoint";
44
+ return serveDiscoveryDocument(getSoftureConfig(), (config) => getProtectedResourceMetadata(config, variant));
45
+ }
46
+
47
+ /** `POST /api/oauth/register`. */
48
+ export function registerOAuthClientRoute(request: Request): Promise<Response> {
49
+ return withContext(request, handleClientRegistration);
50
+ }
51
+
52
+ /** `POST /api/oauth/token`. */
53
+ export function exchangeOAuthTokenRoute(request: Request): Promise<Response> {
54
+ return withContext(request, handleTokenRequest);
55
+ }
56
+
57
+ /** `POST /api/oauth/authorize`: the consent form's decision, answered with `303`. */
58
+ export function decideOAuthAuthorizationRoute(request: Request): Promise<Response> {
59
+ return withContext(request, async (ctx, req) => {
60
+ const user = await getCurrentUser();
61
+ return handleAuthorizationDecision(ctx, req, user?.id ?? null);
62
+ });
63
+ }
64
+
65
+ export { answerOAuthPreflight };
@@ -7,14 +7,15 @@ import { requireUser } from "@softure-ai/auth/next";
7
7
  import { formatMessage, type SoftureConfig } from "@softure-ai/core";
8
8
  import { getSoftureConfig } from "@softure-ai/core/next";
9
9
  import { Card } from "@softure-ai/ui";
10
- import type { AccessTokenStatus, AccessTokenView } from "../contract.js";
10
+ import type { AccessTokenStatus, AccessTokenView, OAuthGrantView } from "../contract.js";
11
11
  import type { McpAccessMessages } from "../messages/index.js";
12
12
  import type { McpAccessOptions } from "../options.js";
13
13
  import { getLocalizedText, getMcpAccessMessages, getMcpAccessOptions, getMcpAccessRoutes } from "../server/options.js";
14
+ import { listOAuthGrants } from "../server/oauth.js";
14
15
  import { listAccessTokens } from "../server/tokens.js";
15
16
  import { getAccessTokenStatus } from "../token-status.js";
16
- import { TokenManager, type TokenManagerRow, type TokenManagerTool } from "../ui/token-manager.js";
17
- import { issueTokenAction, revokeTokenAction } from "./actions.js";
17
+ import { TokenManager, type TokenManagerGrant, type TokenManagerRow, type TokenManagerTool } from "../ui/token-manager.js";
18
+ import { issueTokenAction, revokeGrantAction, revokeTokenAction } from "./actions.js";
18
19
  import { getMcpAccessContext } from "./context.js";
19
20
  import { formatDate, formatDateTime } from "./format.js";
20
21
 
@@ -48,6 +49,16 @@ function toRow(token: AccessTokenView, now: Date, config: SoftureConfig, options
48
49
  };
49
50
  }
50
51
 
52
+ function toGrant(grant: OAuthGrantView, config: SoftureConfig, options: McpAccessOptions, messages: McpAccessMessages): TokenManagerGrant {
53
+ const lastUse = grant.lastUsedAt === null ? messages.list.neverUsed : formatMessage(messages.list.lastUsed, { date: formatDateTime(config, grant.lastUsedAt) });
54
+ return {
55
+ id: grant.id,
56
+ clientName: grant.clientName,
57
+ scopeText: grant.canWrite && options.allowWrites ? messages.list.readWrite : messages.list.readOnly,
58
+ details: [formatMessage(messages.grants.connectedOn, { date: formatDate(config, grant.createdAt) }), lastUse],
59
+ };
60
+ }
61
+
51
62
  function toTool(tool: McpAccessOptions["tools"][number], config: SoftureConfig, options: McpAccessOptions, messages: McpAccessMessages): TokenManagerTool {
52
63
  const accessText = tool.access === "read" ? messages.tools.read : options.allowWrites ? messages.tools.write : messages.tools.writeUnavailable;
53
64
  return { name: tool.name, description: getLocalizedText(tool.description, config.locale), accessText };
@@ -61,6 +72,7 @@ export async function McpAccessPage() {
61
72
  const ctx = await getMcpAccessContext(config);
62
73
  const now = ctx.clock.now();
63
74
  const tokens = await listAccessTokens(ctx, user.id);
75
+ const grants = options.oauth.enabled ? await listOAuthGrants(ctx, user.id) : null;
64
76
  return (
65
77
  <main className={LAYOUT_CLASS}>
66
78
  <Card title={messages.page.title} subtitle={messages.page.lead}>
@@ -71,6 +83,7 @@ export async function McpAccessPage() {
71
83
  maxTokens={options.maxTokensPerUser}
72
84
  issueAction={issueTokenAction}
73
85
  revokeAction={revokeTokenAction}
86
+ {...(grants === null ? {} : { grants: grants.map((grant) => toGrant(grant, config, options, messages)), revokeGrantAction })}
74
87
  messages={messages}
75
88
  locale={config.locale}
76
89
  />
package/src/options.ts CHANGED
@@ -11,6 +11,9 @@ export const TOOL_NAME_PATTERN = /^[A-Za-z0-9_.-]{1,128}$/;
11
11
  /** The longest token name the table takes (`access_tokens.name`). */
12
12
  export const MAX_TOKEN_NAME_LENGTH = 60;
13
13
 
14
+ /** The longest value the token check looks at: a longer header is refused before any pattern runs. */
15
+ export const MAX_PRESENTED_TOKEN_LENGTH = 512;
16
+
14
17
  /** Copy per locale; a locale without its own text falls back to `en`. */
15
18
  const localizedTextSchema = z.partialRecord(z.enum(LOCALES), z.string().trim().min(1).max(500));
16
19
 
@@ -26,6 +29,26 @@ const toolSchema = z.strictObject({
26
29
  description: localizedTextSchema.refine((text) => text.en !== undefined, "needs at least an en text"),
27
30
  });
28
31
 
32
+ const oauthSchema = z.strictObject({
33
+ /**
34
+ * Makes the module an OAuth 2.1 authorization server for its endpoint: discovery metadata,
35
+ * dynamic client registration, a consent page and a token endpoint. The app mounts those routes.
36
+ */
37
+ enabled: z.boolean().default(false),
38
+ /** How long an access token issued through OAuth works. Clients refresh it themselves. */
39
+ accessTokenLifetimeMinutes: z.int().min(5).max(1440).default(60),
40
+ /** How long a refresh token works, counted from the last refresh: a grant in use never expires. */
41
+ refreshTokenLifetimeDays: z.int().min(1).max(365).default(90),
42
+ /** How long an authorization code works; RFC 6749 §4.1.2 recommends at most ten minutes. */
43
+ authorizationCodeLifetimeMinutes: z.int().min(1).max(10).default(10),
44
+ });
45
+
46
+ /** A pattern for tokens an app issued before adopting the module: anchored, and stateless. */
47
+ const legacyTokenPatternSchema = z
48
+ .instanceof(RegExp, { message: "must be a RegExp" })
49
+ .refine((pattern) => pattern.source.startsWith("^") && pattern.source.endsWith("$"), "must be anchored with ^ and $")
50
+ .refine((pattern) => !pattern.global && !pattern.sticky, "must not use the g or y flag");
51
+
29
52
  export const mcpAccessOptionsSchema = z
30
53
  .strictObject({
31
54
  /** The name MCP clients list the server under, e.g. "acme". Used in the setup instructions. */
@@ -34,12 +57,19 @@ export const mcpAccessOptionsSchema = z
34
57
  tools: z.array(toolSchema).default([]),
35
58
  /** Lets write tokens be issued and used. Off, every token reads only, write tokens included. */
36
59
  allowWrites: z.boolean().default(false),
37
- /** How long a token works. A choice: longer than a session, never forever. */
60
+ /** How long a token issued on the token page works. A choice: longer than a session, never forever. */
38
61
  tokenLifetimeDays: z.int().min(1).max(365).default(90),
39
62
  /** How many unexpired tokens one account may hold. */
40
63
  maxTokensPerUser: z.int().min(1).max(100).default(20),
41
64
  /** From how many calendar days before its expiry the page warns about a token. */
42
65
  expiryWarningDays: z.int().min(0).max(60).default(14),
66
+ /**
67
+ * Tokens an app issued before it adopted the module, e.g. `/^[0-9a-f]{64}$/`. Such a value is
68
+ * looked up by its sha256 like a token of the module's own shape; new tokens keep the prefix.
69
+ */
70
+ legacyTokenPattern: legacyTokenPatternSchema.optional(),
71
+ /** OAuth 2.1 for MCP clients that connect without a pasted token (claude.ai, ChatGPT). */
72
+ oauth: oauthSchema.prefault({}),
43
73
  })
44
74
  .superRefine((options, context) => {
45
75
  const names = new Set<string>();
@@ -56,3 +86,4 @@ export type McpAccessOptions = z.output<typeof mcpAccessOptionsSchema>;
56
86
  export type McpToolDefinitionInput = z.input<typeof toolSchema>;
57
87
  export type McpToolDefinition = z.output<typeof toolSchema>;
58
88
  export type McpToolAccess = McpToolDefinition["access"];
89
+ export type McpOAuthOptions = z.output<typeof oauthSchema>;
package/src/schema.ts CHANGED
@@ -1,10 +1,54 @@
1
- // Drizzle view of the module's table (migrations/0001_create_access_tokens.sql). The migration is
2
- // the source of truth; this file only types the queries.
1
+ // Drizzle view of the module's tables (migrations/0001_create_access_tokens.sql and
2
+ // 0002_create_oauth_grants.sql). The migrations are the source of truth; this file only types the
3
+ // queries.
3
4
  import { users } from "@softure-ai/auth";
4
- import { boolean, pgSchema, text, timestamp, uuid } from "drizzle-orm/pg-core";
5
+ import { boolean, jsonb, pgSchema, text, timestamp, uuid, type AnyPgColumn } from "drizzle-orm/pg-core";
5
6
 
6
7
  export const mcpSchema = pgSchema("mcp");
7
8
 
9
+ export const oauthClients = mcpSchema.table("oauth_clients", {
10
+ id: uuid("id").primaryKey().defaultRandom(),
11
+ clientId: text("client_id").notNull().unique("oauth_clients_client_id_key"),
12
+ clientName: text("client_name").notNull(),
13
+ redirectUris: jsonb("redirect_uris").$type<string[]>().notNull(),
14
+ tokenEndpointAuthMethod: text("token_endpoint_auth_method").$type<"none" | "client_secret_post" | "client_secret_basic">().notNull(),
15
+ clientSecretHash: text("client_secret_hash"),
16
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
17
+ });
18
+
19
+ export const oauthAuthorizationCodes = mcpSchema.table("oauth_authorization_codes", {
20
+ id: uuid("id").primaryKey().defaultRandom(),
21
+ codeHash: text("code_hash").notNull().unique("oauth_authorization_codes_code_hash_key"),
22
+ clientId: uuid("client_id")
23
+ .notNull()
24
+ .references(() => oauthClients.id, { onDelete: "cascade" }),
25
+ userId: uuid("user_id")
26
+ .notNull()
27
+ .references(() => users.id, { onDelete: "cascade" }),
28
+ redirectUri: text("redirect_uri").notNull(),
29
+ codeChallenge: text("code_challenge").notNull(),
30
+ canWrite: boolean("can_write").notNull(),
31
+ expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
32
+ usedAt: timestamp("used_at", { withTimezone: true }),
33
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
34
+ });
35
+
36
+ export const oauthGrants = mcpSchema.table("oauth_grants", {
37
+ id: uuid("id").primaryKey().defaultRandom(),
38
+ userId: uuid("user_id")
39
+ .notNull()
40
+ .references(() => users.id, { onDelete: "cascade" }),
41
+ clientId: uuid("client_id")
42
+ .notNull()
43
+ .references(() => oauthClients.id, { onDelete: "cascade" }),
44
+ canWrite: boolean("can_write").notNull(),
45
+ refreshTokenHash: text("refresh_token_hash").notNull().unique("oauth_grants_refresh_token_hash_key"),
46
+ previousRefreshTokenHash: text("previous_refresh_token_hash"),
47
+ refreshExpiresAt: timestamp("refresh_expires_at", { withTimezone: true }).notNull(),
48
+ lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
49
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
50
+ });
51
+
8
52
  export const accessTokens = mcpSchema.table("access_tokens", {
9
53
  id: uuid("id").primaryKey().defaultRandom(),
10
54
  userId: uuid("user_id")
@@ -16,4 +60,8 @@ export const accessTokens = mcpSchema.table("access_tokens", {
16
60
  createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
17
61
  expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
18
62
  lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
63
+ grantId: uuid("grant_id").references((): AnyPgColumn => oauthGrants.id, { onDelete: "cascade" }),
19
64
  });
65
+
66
+ export type OAuthClientRow = typeof oauthClients.$inferSelect;
67
+ export type OAuthTokenEndpointAuthMethod = OAuthClientRow["tokenEndpointAuthMethod"];
@@ -1,4 +1,4 @@
1
- // The MCP endpoint (FIRE_TRACKER `src/app/api/mcp/route.ts`, generalised). It takes a Web
1
+ // The MCP endpoint (an adopting app's MCP route, generalised). It takes a Web
2
2
  // `Request` and returns a `Response`, so it runs under any fetch-shaped host; the Next adapter only
3
3
  // supplies the context. Order: identify the client, count the request, verify the Bearer token,
4
4
  // then hand the request to a fresh server from the app's factory. Each refusal happens before the
@@ -15,12 +15,11 @@ import {
15
15
  import { errorLogLabel, getModule } from "@softure-ai/core";
16
16
  import { consumeRateLimit, identifyClient } from "@softure-ai/security/server";
17
17
  import type { McpServerIdentity } from "../contract.js";
18
+ import { getProtectedResourceMetadataUrl, isOAuthEnabled } from "./oauth-http.js";
19
+ import { MCP_READ_SCOPE, MCP_WRITE_SCOPE } from "./scopes.js";
18
20
  import { verifyAccessToken, type McpAccessContext } from "./tokens.js";
19
21
 
20
- /** Every valid token reads. */
21
- export const MCP_READ_SCOPE = "mcp:read";
22
- /** A token that may write: issued for writes, and the app allows them. */
23
- export const MCP_WRITE_SCOPE = "mcp:write";
22
+ export { MCP_READ_SCOPE, MCP_WRITE_SCOPE };
24
23
 
25
24
  /** The security bucket the endpoint counts in; `MCP_RATE_LIMIT_BUCKETS` holds its default. */
26
25
  export const MCP_RATE_LIMIT_BUCKET = "mcp";
@@ -89,10 +88,14 @@ export function createMcpEndpoint({ createServer }: McpEndpointOptions): McpEndp
89
88
  /**
90
89
  * The verified identity as the SDK's `AuthInfo`, or the challenge response. Unknown, revoked,
91
90
  * expired and malformed tokens get the same `401 invalid_token`. Expiry is decided by the query on
92
- * the module's clock, not by the SDK's own check on the wall clock.
91
+ * the module's clock, not by the SDK's own check on the wall clock. With OAuth on, the challenge
92
+ * names the protected resource metadata (RFC 9728 §5.1): that is how claude.ai and ChatGPT find
93
+ * the authorization server.
93
94
  */
94
95
  async function authenticate(ctx: McpAccessContext, header: string | null): Promise<AuthInfo | Response> {
95
- const challenge = { requiredScopes: [MCP_READ_SCOPE] };
96
+ const challenge = isOAuthEnabled(ctx.config)
97
+ ? { requiredScopes: [MCP_READ_SCOPE], resourceMetadataUrl: getProtectedResourceMetadataUrl(ctx.config) }
98
+ : { requiredScopes: [MCP_READ_SCOPE] };
96
99
  if (header === null || !BEARER_PREFIX.test(header)) {
97
100
  return bearerAuthChallengeResponse(new OAuthError(OAuthErrorCode.InvalidToken, "Missing Bearer token"), challenge);
98
101
  }