@softure-ai/mcp-access 0.0.0-stage → 0.1.5

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 (121) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +177 -2
  3. package/dist/client-setup.d.ts +32 -0
  4. package/dist/client-setup.d.ts.map +1 -0
  5. package/dist/client-setup.js +57 -0
  6. package/dist/client-setup.js.map +1 -0
  7. package/dist/contract.d.ts +80 -0
  8. package/dist/contract.d.ts.map +1 -0
  9. package/dist/contract.js +2 -0
  10. package/dist/contract.js.map +1 -0
  11. package/dist/index.d.ts +119 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +50 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/messages/en.d.ts +77 -0
  16. package/dist/messages/en.d.ts.map +1 -0
  17. package/dist/messages/en.js +77 -0
  18. package/dist/messages/en.js.map +1 -0
  19. package/dist/messages/index.d.ts +161 -0
  20. package/dist/messages/index.d.ts.map +1 -0
  21. package/dist/messages/index.js +14 -0
  22. package/dist/messages/index.js.map +1 -0
  23. package/dist/messages/pl.d.ts +3 -0
  24. package/dist/messages/pl.d.ts.map +1 -0
  25. package/dist/messages/pl.js +77 -0
  26. package/dist/messages/pl.js.map +1 -0
  27. package/dist/next/actions.d.ts +6 -0
  28. package/dist/next/actions.d.ts.map +1 -0
  29. package/dist/next/actions.js +88 -0
  30. package/dist/next/actions.js.map +1 -0
  31. package/dist/next/context.d.ts +4 -0
  32. package/dist/next/context.d.ts.map +1 -0
  33. package/dist/next/context.js +13 -0
  34. package/dist/next/context.js.map +1 -0
  35. package/dist/next/format.d.ts +4 -0
  36. package/dist/next/format.d.ts.map +1 -0
  37. package/dist/next/format.js +7 -0
  38. package/dist/next/format.js.map +1 -0
  39. package/dist/next/index.d.ts +4 -0
  40. package/dist/next/index.d.ts.map +1 -0
  41. package/dist/next/index.js +6 -0
  42. package/dist/next/index.js.map +1 -0
  43. package/dist/next/pages.d.ts +2 -0
  44. package/dist/next/pages.d.ts.map +1 -0
  45. package/dist/next/pages.js +61 -0
  46. package/dist/next/pages.js.map +1 -0
  47. package/dist/next/route.d.ts +3 -0
  48. package/dist/next/route.d.ts.map +1 -0
  49. package/dist/next/route.js +24 -0
  50. package/dist/next/route.js.map +1 -0
  51. package/dist/options.d.ts +43 -0
  52. package/dist/options.d.ts.map +1 -0
  53. package/dist/options.js +47 -0
  54. package/dist/options.js.map +1 -0
  55. package/dist/schema.d.ts +145 -0
  56. package/dist/schema.d.ts.map +1 -0
  57. package/dist/schema.js +18 -0
  58. package/dist/schema.js.map +1 -0
  59. package/dist/server/endpoint.d.ts +26 -0
  60. package/dist/server/endpoint.d.ts.map +1 -0
  61. package/dist/server/endpoint.js +107 -0
  62. package/dist/server/endpoint.js.map +1 -0
  63. package/dist/server/health.d.ts +3 -0
  64. package/dist/server/health.d.ts.map +1 -0
  65. package/dist/server/health.js +11 -0
  66. package/dist/server/health.js.map +1 -0
  67. package/dist/server/index.d.ts +6 -0
  68. package/dist/server/index.d.ts.map +1 -0
  69. package/dist/server/index.js +8 -0
  70. package/dist/server/index.js.map +1 -0
  71. package/dist/server/options.d.ts +20 -0
  72. package/dist/server/options.d.ts.map +1 -0
  73. package/dist/server/options.js +40 -0
  74. package/dist/server/options.js.map +1 -0
  75. package/dist/server/privacy.d.ts +15 -0
  76. package/dist/server/privacy.d.ts.map +1 -0
  77. package/dist/server/privacy.js +36 -0
  78. package/dist/server/privacy.js.map +1 -0
  79. package/dist/server/tokens.d.ts +66 -0
  80. package/dist/server/tokens.d.ts.map +1 -0
  81. package/dist/server/tokens.js +138 -0
  82. package/dist/server/tokens.js.map +1 -0
  83. package/dist/token-status.d.ts +9 -0
  84. package/dist/token-status.d.ts.map +1 -0
  85. package/dist/token-status.js +14 -0
  86. package/dist/token-status.js.map +1 -0
  87. package/dist/ui/index.d.ts +2 -0
  88. package/dist/ui/index.d.ts.map +1 -0
  89. package/dist/ui/index.js +4 -0
  90. package/dist/ui/index.js.map +1 -0
  91. package/dist/ui/token-manager.d.ts +43 -0
  92. package/dist/ui/token-manager.d.ts.map +1 -0
  93. package/dist/ui/token-manager.js +50 -0
  94. package/dist/ui/token-manager.js.map +1 -0
  95. package/migrations/0001_create_access_tokens.sql +24 -0
  96. package/module.json +15 -0
  97. package/package.json +65 -4
  98. package/src/client-setup.ts +71 -0
  99. package/src/contract.ts +80 -0
  100. package/src/index.ts +81 -0
  101. package/src/messages/en.ts +76 -0
  102. package/src/messages/index.ts +18 -0
  103. package/src/messages/pl.ts +78 -0
  104. package/src/next/actions.ts +91 -0
  105. package/src/next/context.ts +14 -0
  106. package/src/next/format.ts +10 -0
  107. package/src/next/index.ts +5 -0
  108. package/src/next/next-modules.d.ts +16 -0
  109. package/src/next/pages.tsx +80 -0
  110. package/src/next/route.ts +24 -0
  111. package/src/options.ts +58 -0
  112. package/src/schema.ts +19 -0
  113. package/src/server/endpoint.ts +141 -0
  114. package/src/server/health.ts +12 -0
  115. package/src/server/index.ts +36 -0
  116. package/src/server/options.ts +54 -0
  117. package/src/server/privacy.ts +49 -0
  118. package/src/server/tokens.ts +188 -0
  119. package/src/token-status.ts +27 -0
  120. package/src/ui/index.ts +11 -0
  121. package/src/ui/token-manager.tsx +254 -0
@@ -0,0 +1,80 @@
1
+ // Result types and error codes of the mcp-access module. No user-facing copy here: the UI
2
+ // translates codes through `messages` (docs/02-module-standard.md §6).
3
+ import type { CoreErrorCode } from "@softure-ai/core";
4
+
5
+ export type McpAccessErrorCode =
6
+ | "mcp-access.name_required"
7
+ | "mcp-access.name_too_long"
8
+ | "mcp-access.token_limit_reached"
9
+ | "mcp-access.token_not_found";
10
+
11
+ /** Every code the token page can show: its own, a missing session and the generic ones. */
12
+ export type TokenFormErrorCode = McpAccessErrorCode | "auth.unauthenticated" | CoreErrorCode;
13
+
14
+ /** What the app's MCP server factory receives for one request. */
15
+ export interface McpServerIdentity {
16
+ /** The auth user id of the token's owner: every query of the server is scoped to it. */
17
+ readonly userId: string;
18
+ /** Whether write tools may be registered: the app allows writes and the token was issued for them. */
19
+ readonly canWrite: boolean;
20
+ /** The token's id, for the server's own logs. Never the token. */
21
+ readonly tokenId: string;
22
+ }
23
+
24
+ /** A stored token as its owner sees it. The hash never leaves the server functions. */
25
+ export interface AccessTokenView {
26
+ readonly id: string;
27
+ readonly name: string;
28
+ /** Issued with write access (it still writes only while the app allows writes). */
29
+ readonly canWrite: boolean;
30
+ readonly createdAt: Date;
31
+ readonly expiresAt: Date;
32
+ readonly lastUsedAt: Date | null;
33
+ }
34
+
35
+ /** Where a token stands on its expiry, for the list. */
36
+ export type AccessTokenStatus =
37
+ | { readonly kind: "active" }
38
+ /** `daysLeft`: calendar days in the app's time zone, from 0 (today) to the warning threshold. */
39
+ | { readonly kind: "expiring"; readonly daysLeft: number }
40
+ | { readonly kind: "expired" };
41
+
42
+ /** Ready-to-paste setup for MCP clients, built around the plaintext token. */
43
+ export interface McpClientSetup {
44
+ readonly serverName: string;
45
+ readonly endpointUrl: string;
46
+ /** `claude mcp add …` for Claude Code, with the token in it. */
47
+ readonly claudeCodeCommand: string;
48
+ /** A prompt asking the assistant to run that command and confirm the connection. */
49
+ readonly assistantPrompt: string;
50
+ /** A `claude-cli://` link that opens Claude Code with the prompt (it runs nothing by itself). */
51
+ readonly claudeCodeLink: string;
52
+ /** `mcpServers` JSON for clients configured by file (Cursor, VS Code, Windsurf). */
53
+ readonly jsonConfig: string;
54
+ /** Claude Desktop's file, bridged through `mcp-remote` (it cannot send headers itself). */
55
+ readonly desktopConfig: string;
56
+ /** The `Authorization` header value, for clients with a header field. */
57
+ readonly authorizationHeader: string;
58
+ }
59
+
60
+ /** A token right after it was issued: the only moment its plaintext exists outside the client. */
61
+ export interface IssuedToken {
62
+ readonly id: string;
63
+ readonly name: string;
64
+ readonly canWrite: boolean;
65
+ /** The expiry, already formatted in the app's locale and time zone. */
66
+ readonly expiresText: string;
67
+ readonly setup: McpClientSetup;
68
+ }
69
+
70
+ /** What the issue action returns to its form (`useActionState`). */
71
+ export type IssueTokenFormState =
72
+ | { readonly status: "idle" }
73
+ | { readonly status: "ok"; readonly issued: IssuedToken }
74
+ | { readonly status: "error"; readonly error: TokenFormErrorCode };
75
+
76
+ /** What the revoke action returns to one token's form. */
77
+ export type RevokeTokenFormState =
78
+ | { readonly status: "idle" }
79
+ | { readonly status: "ok" }
80
+ | { readonly status: "error"; readonly error: TokenFormErrorCode };
package/src/index.ts ADDED
@@ -0,0 +1,81 @@
1
+ // Public API of @softure-ai/mcp-access: the module factory for softure.config.ts, its rate limit
2
+ // bucket, types, messages, table and the pure helpers for token status and client setup. Database
3
+ // work and the endpoint are in `@softure-ai/mcp-access/server`, the Next.js adapter (route, page,
4
+ // actions) in `/next`, the token manager in `/ui`.
5
+ import { defineModule, resolveMigrationsDir } from "@softure-ai/core";
6
+ import { mcpAccessMessages } from "./messages/index.js";
7
+ import { mcpAccessOptionsSchema } from "./options.js";
8
+ import { checkAccessTokensTable } from "./server/health.js";
9
+ import { mcpAccessPrivacyContributor } from "./server/privacy.js";
10
+
11
+ export const MODULE_ID = "mcp-access";
12
+
13
+ /**
14
+ * The rate limit bucket of `POST /api/mcp`, to spread into `security({ buckets })`: requests per
15
+ * client address, counted before the token is verified. Every request counts, so the limit is for
16
+ * the traffic behind one address (several assistants share it), not for one token.
17
+ */
18
+ export const MCP_RATE_LIMIT_BUCKETS = {
19
+ mcp: { limit: 200, windowMinutes: 15 },
20
+ } as const;
21
+
22
+ /**
23
+ * Enables MCP access in `softure.config.ts` (after `security` and `auth`):
24
+ * `mcpAccess({ serverName: "acme", tools: [{ name: "list_orders", access: "read", description: { en: "Lists your orders." } }] })`.
25
+ */
26
+ export const mcpAccess = defineModule({
27
+ manifest: {
28
+ id: MODULE_ID,
29
+ version: "0.1.5",
30
+ dependsOn: { security: "^0.1.0", auth: "^0.1.0" },
31
+ dbSchema: "mcp",
32
+ tables: ["access_tokens"],
33
+ env: [],
34
+ switches: [],
35
+ routes: { page: "/account/mcp", endpoint: "/api/mcp" },
36
+ mount: [
37
+ { kind: "page", path: "app/account/mcp/page.tsx", export: "McpAccessPage" },
38
+ { kind: "route-handler", path: "app/api/mcp/route.ts", export: "createMcpRoute" },
39
+ ],
40
+ privacy: { exports: true, deletes: true },
41
+ },
42
+ messages: mcpAccessMessages,
43
+ options: mcpAccessOptionsSchema,
44
+ migrations: { dir: resolveMigrationsDir(import.meta.url, "../migrations/") },
45
+ privacy: mcpAccessPrivacyContributor,
46
+ health: checkAccessTokensTable,
47
+ });
48
+
49
+ export {
50
+ getAuthorizationHeader,
51
+ getClaudeCodeCommand,
52
+ getClaudeCodeLink,
53
+ getDesktopConfig,
54
+ getJsonConfig,
55
+ getMcpClientSetup,
56
+ type McpClientSetupInput,
57
+ } from "./client-setup.js";
58
+ export type {
59
+ AccessTokenStatus,
60
+ AccessTokenView,
61
+ IssuedToken,
62
+ IssueTokenFormState,
63
+ McpAccessErrorCode,
64
+ McpClientSetup,
65
+ McpServerIdentity,
66
+ RevokeTokenFormState,
67
+ TokenFormErrorCode,
68
+ } from "./contract.js";
69
+ export { getTokenErrorMessage, mcpAccessMessages, type McpAccessMessages } from "./messages/index.js";
70
+ export {
71
+ MAX_TOKEN_NAME_LENGTH,
72
+ SERVER_NAME_PATTERN,
73
+ TOOL_NAME_PATTERN,
74
+ type McpAccessOptions,
75
+ type McpAccessOptionsInput,
76
+ type McpToolAccess,
77
+ type McpToolDefinition,
78
+ type McpToolDefinitionInput,
79
+ } from "./options.js";
80
+ export { accessTokens } from "./schema.js";
81
+ export { getAccessTokenStatus, type AccessTokenStatusOptions } from "./token-status.js";
@@ -0,0 +1,76 @@
1
+ export const en = {
2
+ page: {
3
+ title: "AI assistant access",
4
+ lead: "Connect an AI assistant such as Claude to your account over MCP. Each assistant gets its own token, which you can revoke at any time.",
5
+ },
6
+ tools: {
7
+ title: "What an assistant can do",
8
+ empty: "The app lists no tools yet.",
9
+ read: "Reads",
10
+ write: "Changes data",
11
+ writeUnavailable: "Changes data (turned off in this app)",
12
+ },
13
+ issue: {
14
+ title: "New token",
15
+ name: "Name",
16
+ nameHint: "Where you will use it, e.g. Laptop or Claude Desktop.",
17
+ canWrite: "Allow changes",
18
+ canWriteHint: "Without it, the assistant can only read.",
19
+ submit: "Create token",
20
+ pending: "Creating…",
21
+ limit: "You can hold up to {max} active tokens.",
22
+ },
23
+ issued: {
24
+ title: "Token created: {name}",
25
+ once: "Copy it now. It is shown only once; after you leave this page, only its name remains.",
26
+ expires: "Works until {date}.",
27
+ readOnly: "This token can only read.",
28
+ readWrite: "This token can read and change data.",
29
+ promptTitle: "Easiest: let Claude Code set it up",
30
+ promptHint: "Paste this into a conversation with Claude Code. It runs the command and checks the connection.",
31
+ openClaudeCode: "Open in Claude Code",
32
+ commandTitle: "Terminal command (Claude Code)",
33
+ jsonTitle: "Configuration file (Cursor, VS Code, Windsurf)",
34
+ desktopTitle: "Claude Desktop (claude_desktop_config.json)",
35
+ headerTitle: "Other clients: server address and Authorization header",
36
+ copy: "Copy",
37
+ copied: "Copied",
38
+ done: "Done, hide the token",
39
+ },
40
+ setup: {
41
+ assistantPrompt: "Connect to my {serverName} account over MCP.\nRun: {command}\nThen check /mcp and tell me whether the server \"{serverName}\" is connected.",
42
+ },
43
+ list: {
44
+ title: "Your tokens",
45
+ empty: "You have no tokens yet.",
46
+ readOnly: "Read only",
47
+ readWrite: "Read and change",
48
+ active: "Active",
49
+ expired: "Expired",
50
+ expiresToday: "Expires today",
51
+ expiresTomorrow: "Expires tomorrow",
52
+ expiresInDays: "Expires in {days} days",
53
+ expiresOn: "Valid until {date}",
54
+ expiredOn: "Expired on {date}",
55
+ createdOn: "Created {date}",
56
+ lastUsed: "Last used {date}",
57
+ neverUsed: "Not used yet",
58
+ revoke: "Revoke",
59
+ revoking: "Revoking…",
60
+ },
61
+ errors: {
62
+ "mcp-access": {
63
+ name_required: "Give the token a name, so you can tell it from the others.",
64
+ name_too_long: "The name is too long.",
65
+ token_limit_reached: "You already hold the most active tokens allowed. Revoke one before creating another.",
66
+ token_not_found: "This token no longer exists. It may have been revoked already.",
67
+ },
68
+ auth: {
69
+ unauthenticated: "Your session has ended. Log in again.",
70
+ },
71
+ core: {
72
+ database_failed: "Something went wrong on our side. Try again in a moment.",
73
+ unexpected: "Something went wrong on our side. Try again in a moment.",
74
+ },
75
+ },
76
+ };
@@ -0,0 +1,18 @@
1
+ import type { TokenFormErrorCode } from "../contract.js";
2
+ import { en } from "./en.js";
3
+ import { pl } from "./pl.js";
4
+
5
+ /** Complete default dictionaries; apps pass partial overrides per locale. */
6
+ export const mcpAccessMessages = { en, pl };
7
+
8
+ export type McpAccessMessages = typeof en;
9
+
10
+ /** The copy for an error code; an unknown code gets the generic failure. */
11
+ export function getTokenErrorMessage(messages: McpAccessMessages, code: TokenFormErrorCode): string {
12
+ const separator = code.lastIndexOf(".");
13
+ const namespace = code.slice(0, separator);
14
+ const name = code.slice(separator + 1);
15
+ const groups = messages.errors as Readonly<Record<string, Readonly<Record<string, string>>>>;
16
+ const group = Object.hasOwn(groups, namespace) ? groups[namespace] : undefined;
17
+ return (group !== undefined && Object.hasOwn(group, name) ? group[name] : undefined) ?? messages.errors.core.unexpected;
18
+ }
@@ -0,0 +1,78 @@
1
+ import type { en } from "./en.js";
2
+
3
+ export const pl: typeof en = {
4
+ page: {
5
+ title: "Dostęp dla asystenta AI",
6
+ lead: "Połącz asystenta AI, na przykład Claude, ze swoim kontem przez MCP. Każdy asystent dostaje własny token, który możesz w każdej chwili unieważnić.",
7
+ },
8
+ tools: {
9
+ title: "Co może asystent",
10
+ empty: "Aplikacja nie udostępnia jeszcze żadnych narzędzi.",
11
+ read: "Odczyt",
12
+ write: "Zmienia dane",
13
+ writeUnavailable: "Zmienia dane (wyłączone w tej aplikacji)",
14
+ },
15
+ issue: {
16
+ title: "Nowy token",
17
+ name: "Nazwa",
18
+ nameHint: "Gdzie go użyjesz, na przykład Laptop albo Claude Desktop.",
19
+ canWrite: "Pozwól na zmiany",
20
+ canWriteHint: "Bez tego asystent może tylko czytać.",
21
+ submit: "Utwórz token",
22
+ pending: "Tworzenie…",
23
+ limit: "Możesz mieć najwyżej {max} aktywnych tokenów.",
24
+ },
25
+ issued: {
26
+ title: "Utworzono token: {name}",
27
+ once: "Skopiuj go teraz. Pokazujemy go tylko raz; gdy opuścisz tę stronę, zostanie tylko jego nazwa.",
28
+ expires: "Działa do {date}.",
29
+ readOnly: "Ten token może tylko czytać.",
30
+ readWrite: "Ten token może czytać i zmieniać dane.",
31
+ promptTitle: "Najprościej: niech Claude Code sam to skonfiguruje",
32
+ promptHint: "Wklej to do rozmowy z Claude Code. Wykona polecenie i sprawdzi połączenie.",
33
+ openClaudeCode: "Otwórz w Claude Code",
34
+ commandTitle: "Polecenie w terminalu (Claude Code)",
35
+ jsonTitle: "Plik konfiguracyjny (Cursor, VS Code, Windsurf)",
36
+ desktopTitle: "Claude Desktop (claude_desktop_config.json)",
37
+ headerTitle: "Inne klienty: adres serwera i nagłówek Authorization",
38
+ copy: "Kopiuj",
39
+ copied: "Skopiowano",
40
+ done: "Gotowe, ukryj token",
41
+ },
42
+ setup: {
43
+ assistantPrompt: "Połącz się z moim kontem {serverName} przez MCP.\nWykonaj: {command}\nPotem sprawdź /mcp i powiedz mi, czy serwer „{serverName}” jest połączony.",
44
+ },
45
+ list: {
46
+ title: "Twoje tokeny",
47
+ empty: "Nie masz jeszcze żadnych tokenów.",
48
+ readOnly: "Tylko odczyt",
49
+ readWrite: "Odczyt i zmiany",
50
+ active: "Aktywny",
51
+ expired: "Wygasł",
52
+ expiresToday: "Wygasa dziś",
53
+ expiresTomorrow: "Wygasa jutro",
54
+ expiresInDays: "Wygasa za {days} dni",
55
+ expiresOn: "Ważny do {date}",
56
+ expiredOn: "Wygasł {date}",
57
+ createdOn: "Utworzony {date}",
58
+ lastUsed: "Ostatnio użyty {date}",
59
+ neverUsed: "Jeszcze nieużywany",
60
+ revoke: "Unieważnij",
61
+ revoking: "Unieważnianie…",
62
+ },
63
+ errors: {
64
+ "mcp-access": {
65
+ name_required: "Nadaj tokenowi nazwę, żeby odróżnić go od pozostałych.",
66
+ name_too_long: "Nazwa jest za długa.",
67
+ token_limit_reached: "Masz już najwięcej aktywnych tokenów, ile można. Unieważnij któryś, zanim utworzysz nowy.",
68
+ token_not_found: "Ten token już nie istnieje. Mógł zostać unieważniony.",
69
+ },
70
+ auth: {
71
+ unauthenticated: "Twoja sesja wygasła. Zaloguj się ponownie.",
72
+ },
73
+ core: {
74
+ database_failed: "Coś poszło nie tak po naszej stronie. Spróbuj ponownie za chwilę.",
75
+ unexpected: "Coś poszło nie tak po naszej stronie. Spróbuj ponownie za chwilę.",
76
+ },
77
+ },
78
+ };
@@ -0,0 +1,91 @@
1
+ "use server";
2
+
3
+ // The token page's server actions. The owner comes from the session (never from a bound argument,
4
+ // which the client controls, docs/02 §8) before the form is read, and every query is scoped to
5
+ // that owner. Unexpected failures become `safeError` codes; the token never reaches a log.
6
+ import { getCurrentUser } from "@softure-ai/auth/next";
7
+ import { errorLogLabel, formatMessage, safeError, type SoftureConfig } from "@softure-ai/core";
8
+ import { getSoftureConfig } from "@softure-ai/core/next";
9
+ import { revalidatePath } from "next/cache";
10
+ import { z } from "zod";
11
+ import { getMcpClientSetup } from "../client-setup.js";
12
+ import type { IssueTokenFormState, RevokeTokenFormState } from "../contract.js";
13
+ import { MAX_TOKEN_NAME_LENGTH } from "../options.js";
14
+ import { getMcpAccessMessages, getMcpAccessOptions, getMcpAccessRoutes, getMcpEndpointUrl } from "../server/options.js";
15
+ import { issueAccessToken, revokeAccessToken } from "../server/tokens.js";
16
+ import { getMcpAccessContext } from "./context.js";
17
+ import { formatDate } from "./format.js";
18
+
19
+ /** Longer values are cut: the server functions refuse them anyway, and nothing huge is echoed back. */
20
+ const MAX_FIELD_LENGTH = MAX_TOKEN_NAME_LENGTH * 4;
21
+ const text = z
22
+ .string()
23
+ .catch("")
24
+ .transform((value) => value.slice(0, MAX_FIELD_LENGTH));
25
+
26
+ const issueInput = z.object({
27
+ name: text,
28
+ // A checked HTML checkbox sends "on"; an unchecked one sends nothing, which means read only.
29
+ canWrite: z.string().nullable().catch(null),
30
+ });
31
+ const revokeInput = z.object({ id: text });
32
+
33
+ function reportFailure(operation: string, error: unknown) {
34
+ console.error(`@softure-ai/mcp-access: ${operation} failed: ${errorLogLabel(error)}`);
35
+ return safeError(error).error;
36
+ }
37
+
38
+ function refreshPage(config: SoftureConfig): void {
39
+ revalidatePath(getMcpAccessRoutes(config).page);
40
+ }
41
+
42
+ /** Issues a token for the signed-in user and returns its plaintext with the setup snippets, once. */
43
+ export async function issueTokenAction(_previous: IssueTokenFormState, formData: FormData): Promise<IssueTokenFormState> {
44
+ const config = getSoftureConfig();
45
+ const user = await getCurrentUser();
46
+ if (user === null) return { status: "error", error: "auth.unauthenticated" };
47
+
48
+ // Every field has a `catch`, so parsing cannot fail.
49
+ const input = issueInput.parse({ name: formData.get("name"), canWrite: formData.get("canWrite") });
50
+ try {
51
+ const result = await issueAccessToken(await getMcpAccessContext(config), { userId: user.id, name: input.name, canWrite: input.canWrite !== null });
52
+ if (!result.ok) return { status: "error", error: result.error };
53
+ refreshPage(config);
54
+ const issued = result.value;
55
+ const messages = getMcpAccessMessages(config);
56
+ return {
57
+ status: "ok",
58
+ issued: {
59
+ id: issued.id,
60
+ name: issued.name,
61
+ canWrite: issued.canWrite,
62
+ expiresText: formatMessage(messages.issued.expires, { date: formatDate(config, issued.expiresAt) }),
63
+ setup: getMcpClientSetup({
64
+ serverName: getMcpAccessOptions(config).serverName,
65
+ endpointUrl: getMcpEndpointUrl(config),
66
+ token: issued.token,
67
+ promptTemplate: messages.setup.assistantPrompt,
68
+ }),
69
+ },
70
+ };
71
+ } catch (error) {
72
+ return { status: "error", error: reportFailure("issuing a token", error) };
73
+ }
74
+ }
75
+
76
+ /** Revokes one of the signed-in user's tokens; another account's id matches nothing. */
77
+ export async function revokeTokenAction(_previous: RevokeTokenFormState, formData: FormData): Promise<RevokeTokenFormState> {
78
+ const config = getSoftureConfig();
79
+ const user = await getCurrentUser();
80
+ if (user === null) return { status: "error", error: "auth.unauthenticated" };
81
+
82
+ const input = revokeInput.parse({ id: formData.get("id") });
83
+ try {
84
+ const result = await revokeAccessToken(await getMcpAccessContext(config), { userId: user.id, tokenId: input.id });
85
+ if (!result.ok) return { status: "error", error: result.error };
86
+ refreshPage(config);
87
+ return { status: "ok" };
88
+ } catch (error) {
89
+ return { status: "error", error: reportFailure("revoking a token", error) };
90
+ }
91
+ }
@@ -0,0 +1,14 @@
1
+ // What the adapter hands the server functions: the registered config, the process-wide database
2
+ // handle and the wall clock.
3
+ import { systemClock, type SoftureConfig } from "@softure-ai/core";
4
+ import { getSoftureConfig } from "@softure-ai/core/next";
5
+ import { getSharedDatabase } from "@softure-ai/db";
6
+ import type { McpAccessContext } from "../server/tokens.js";
7
+
8
+ export async function getMcpAccessContext(config: SoftureConfig = getSoftureConfig()): Promise<McpAccessContext> {
9
+ if (config.database === null) {
10
+ throw new Error("@softure-ai/mcp-access: softure.config.ts has no database; mcp-access needs one");
11
+ }
12
+ const { db } = await getSharedDatabase(config.database.url);
13
+ return { db, clock: systemClock, config };
14
+ }
@@ -0,0 +1,10 @@
1
+ // Dates on the token page, in the app's locale and time zone.
2
+ import type { SoftureConfig } from "@softure-ai/core";
3
+
4
+ export function formatDate(config: SoftureConfig, date: Date): string {
5
+ return new Intl.DateTimeFormat(config.locale, { dateStyle: "long", timeZone: config.timezone }).format(date);
6
+ }
7
+
8
+ export function formatDateTime(config: SoftureConfig, date: Date): string {
9
+ return new Intl.DateTimeFormat(config.locale, { dateStyle: "medium", timeStyle: "short", timeZone: config.timezone }).format(date);
10
+ }
@@ -0,0 +1,5 @@
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";
4
+ export { McpAccessPage } from "./pages.js";
5
+ export { createMcpRoute } from "./route.js";
@@ -0,0 +1,16 @@
1
+ // Next.js has no `exports` map, so NodeNext resolution (tsconfig.base.json) only finds
2
+ // `next/navigation.js` and `next/headers.js`. The code must still import the bare specifiers:
3
+ // Next's bundler aliases them per runtime, and the `.js` form bypasses the alias (measured:
4
+ // `next build` failed to collect a route handler importing `next/navigation.js`, MODULE_UNPARSABLE
5
+ // on the vendored app-router context). These declarations give the bare specifiers their types.
6
+ declare module "next/navigation" {
7
+ export * from "next/navigation.js";
8
+ }
9
+
10
+ declare module "next/headers" {
11
+ export * from "next/headers.js";
12
+ }
13
+
14
+ declare module "next/cache" {
15
+ export * from "next/cache.js";
16
+ }
@@ -0,0 +1,80 @@
1
+ // The token page, ready to mount with one line:
2
+ // `export { McpAccessPage as default } from "@softure-ai/mcp-access/next"`.
3
+ // A server component for the signed-in user; without a session it redirects to the login page and
4
+ // back. Everything the client part shows is prepared here, so dates are formatted once, in the
5
+ // app's locale and time zone, and never differ between the server render and hydration.
6
+ import { requireUser } from "@softure-ai/auth/next";
7
+ import { formatMessage, type SoftureConfig } from "@softure-ai/core";
8
+ import { getSoftureConfig } from "@softure-ai/core/next";
9
+ import { Card } from "@softure-ai/ui";
10
+ import type { AccessTokenStatus, AccessTokenView } from "../contract.js";
11
+ import type { McpAccessMessages } from "../messages/index.js";
12
+ import type { McpAccessOptions } from "../options.js";
13
+ import { getLocalizedText, getMcpAccessMessages, getMcpAccessOptions, getMcpAccessRoutes } from "../server/options.js";
14
+ import { listAccessTokens } from "../server/tokens.js";
15
+ 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";
18
+ import { getMcpAccessContext } from "./context.js";
19
+ import { formatDate, formatDateTime } from "./format.js";
20
+
21
+ const LAYOUT_CLASS = "sft:mx-auto sft:box-border sft:w-full sft:sm:max-w-3xl sft:px-4 sft:py-4";
22
+
23
+ function describeStatus(status: AccessTokenStatus, messages: McpAccessMessages): string {
24
+ switch (status.kind) {
25
+ case "active":
26
+ return messages.list.active;
27
+ case "expired":
28
+ return messages.list.expired;
29
+ case "expiring":
30
+ if (status.daysLeft === 0) return messages.list.expiresToday;
31
+ if (status.daysLeft === 1) return messages.list.expiresTomorrow;
32
+ return formatMessage(messages.list.expiresInDays, { days: status.daysLeft });
33
+ }
34
+ }
35
+
36
+ function toRow(token: AccessTokenView, now: Date, config: SoftureConfig, options: McpAccessOptions, messages: McpAccessMessages): TokenManagerRow {
37
+ const status = getAccessTokenStatus(token.expiresAt, now, { timezone: config.timezone, warningDays: options.expiryWarningDays });
38
+ const isExpired = status.kind === "expired";
39
+ const expiry = formatMessage(isExpired ? messages.list.expiredOn : messages.list.expiresOn, { date: formatDate(config, token.expiresAt) });
40
+ const lastUse = token.lastUsedAt === null ? messages.list.neverUsed : formatMessage(messages.list.lastUsed, { date: formatDateTime(config, token.lastUsedAt) });
41
+ return {
42
+ id: token.id,
43
+ name: token.name,
44
+ scopeText: token.canWrite && options.allowWrites ? messages.list.readWrite : messages.list.readOnly,
45
+ statusText: describeStatus(status, messages),
46
+ isExpired,
47
+ details: [formatMessage(messages.list.createdOn, { date: formatDate(config, token.createdAt) }), expiry, lastUse],
48
+ };
49
+ }
50
+
51
+ function toTool(tool: McpAccessOptions["tools"][number], config: SoftureConfig, options: McpAccessOptions, messages: McpAccessMessages): TokenManagerTool {
52
+ const accessText = tool.access === "read" ? messages.tools.read : options.allowWrites ? messages.tools.write : messages.tools.writeUnavailable;
53
+ return { name: tool.name, description: getLocalizedText(tool.description, config.locale), accessText };
54
+ }
55
+
56
+ export async function McpAccessPage() {
57
+ const config = getSoftureConfig();
58
+ const user = await requireUser({ next: getMcpAccessRoutes(config).page });
59
+ const options = getMcpAccessOptions(config);
60
+ const messages = getMcpAccessMessages(config);
61
+ const ctx = await getMcpAccessContext(config);
62
+ const now = ctx.clock.now();
63
+ const tokens = await listAccessTokens(ctx, user.id);
64
+ return (
65
+ <main className={LAYOUT_CLASS}>
66
+ <Card title={messages.page.title} subtitle={messages.page.lead}>
67
+ <TokenManager
68
+ tools={options.tools.map((tool) => toTool(tool, config, options, messages))}
69
+ tokens={tokens.map((token) => toRow(token, now, config, options, messages))}
70
+ allowWrites={options.allowWrites}
71
+ maxTokens={options.maxTokensPerUser}
72
+ issueAction={issueTokenAction}
73
+ revokeAction={revokeTokenAction}
74
+ messages={messages}
75
+ locale={config.locale}
76
+ />
77
+ </Card>
78
+ </main>
79
+ );
80
+ }
@@ -0,0 +1,24 @@
1
+ // `POST /api/mcp`, built around the app's MCP server factory and mounted with one line:
2
+ // `app/api/mcp/route.ts → export const POST = createMcpRoute({ createServer });`
3
+ // The factory lives in the app, next to its tools; the module brings the rate limit, the token
4
+ // check and the identity the factory receives. GET and DELETE are not exported, so Next answers
5
+ // them with 405: the endpoint is stateless and keeps no sessions.
6
+ import { errorLogLabel } from "@softure-ai/core";
7
+ import { createMcpEndpoint, type McpEndpointOptions } from "../server/endpoint.js";
8
+ import { getMcpAccessContext } from "./context.js";
9
+
10
+ const NO_STORE = { "cache-control": "no-store" };
11
+
12
+ export function createMcpRoute(options: McpEndpointOptions): (request: Request) => Promise<Response> {
13
+ const endpoint = createMcpEndpoint(options);
14
+ return async (request) => {
15
+ let ctx;
16
+ try {
17
+ ctx = await getMcpAccessContext();
18
+ } catch (error) {
19
+ console.error(`@softure-ai/mcp-access: opening the database failed: ${errorLogLabel(error)}`);
20
+ return Response.json({ error: "temporarily_unavailable" }, { status: 503, headers: NO_STORE });
21
+ }
22
+ return endpoint(ctx, request);
23
+ };
24
+ }
package/src/options.ts ADDED
@@ -0,0 +1,58 @@
1
+ // The options an app passes to `mcpAccess({ ... })` in softure.config.ts, parsed at startup.
2
+ import { LOCALES } from "@softure-ai/core";
3
+ import { z } from "zod";
4
+
5
+ /** The name an MCP client lists the server under: no spaces, so a shell command needs no quotes. */
6
+ export const SERVER_NAME_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
7
+
8
+ /** The MCP rule for tool names: 1 to 128 of A-Z, a-z, 0-9, `_`, `-` and `.`. */
9
+ export const TOOL_NAME_PATTERN = /^[A-Za-z0-9_.-]{1,128}$/;
10
+
11
+ /** The longest token name the table takes (`access_tokens.name`). */
12
+ export const MAX_TOKEN_NAME_LENGTH = 60;
13
+
14
+ /** Copy per locale; a locale without its own text falls back to `en`. */
15
+ const localizedTextSchema = z.partialRecord(z.enum(LOCALES), z.string().trim().min(1).max(500));
16
+
17
+ const toolSchema = z.strictObject({
18
+ /** The tool's name as the app's MCP server registers it. */
19
+ name: z.string().regex(TOOL_NAME_PATTERN, "must be 1 to 128 of A-Z, a-z, 0-9, _, - and ."),
20
+ /**
21
+ * `write` tools change data: the app registers them only for a token that can write. They stay
22
+ * listed while `allowWrites` is off, marked as unavailable.
23
+ */
24
+ access: z.enum(["read", "write"]),
25
+ /** What the tool does, for the owner deciding which token to issue. */
26
+ description: localizedTextSchema.refine((text) => text.en !== undefined, "needs at least an en text"),
27
+ });
28
+
29
+ export const mcpAccessOptionsSchema = z
30
+ .strictObject({
31
+ /** The name MCP clients list the server under, e.g. "acme". Used in the setup instructions. */
32
+ serverName: z.string().regex(SERVER_NAME_PATTERN, "must be lowercase letters, digits and inner dashes, at most 64 characters"),
33
+ /** The app's tool catalog, shown on the token page. The MCP server itself comes from the route. */
34
+ tools: z.array(toolSchema).default([]),
35
+ /** Lets write tokens be issued and used. Off, every token reads only, write tokens included. */
36
+ allowWrites: z.boolean().default(false),
37
+ /** How long a token works. A choice: longer than a session, never forever. */
38
+ tokenLifetimeDays: z.int().min(1).max(365).default(90),
39
+ /** How many unexpired tokens one account may hold. */
40
+ maxTokensPerUser: z.int().min(1).max(100).default(20),
41
+ /** From how many calendar days before its expiry the page warns about a token. */
42
+ expiryWarningDays: z.int().min(0).max(60).default(14),
43
+ })
44
+ .superRefine((options, context) => {
45
+ const names = new Set<string>();
46
+ options.tools.forEach((tool, index) => {
47
+ if (names.has(tool.name)) {
48
+ context.addIssue({ code: "custom", path: ["tools", index, "name"], message: `"${tool.name}" is listed twice` });
49
+ }
50
+ names.add(tool.name);
51
+ });
52
+ });
53
+
54
+ export type McpAccessOptionsInput = z.input<typeof mcpAccessOptionsSchema>;
55
+ export type McpAccessOptions = z.output<typeof mcpAccessOptionsSchema>;
56
+ export type McpToolDefinitionInput = z.input<typeof toolSchema>;
57
+ export type McpToolDefinition = z.output<typeof toolSchema>;
58
+ export type McpToolAccess = McpToolDefinition["access"];
package/src/schema.ts ADDED
@@ -0,0 +1,19 @@
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.
3
+ import { users } from "@softure-ai/auth";
4
+ import { boolean, pgSchema, text, timestamp, uuid } from "drizzle-orm/pg-core";
5
+
6
+ export const mcpSchema = pgSchema("mcp");
7
+
8
+ export const accessTokens = mcpSchema.table("access_tokens", {
9
+ id: uuid("id").primaryKey().defaultRandom(),
10
+ userId: uuid("user_id")
11
+ .notNull()
12
+ .references(() => users.id, { onDelete: "cascade" }),
13
+ name: text("name").notNull(),
14
+ tokenHash: text("token_hash").notNull().unique("access_tokens_token_hash_key"),
15
+ canWrite: boolean("can_write").notNull(),
16
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
17
+ expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
18
+ lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
19
+ });