@softure-ai/mcp-access 0.2.9 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +66 -0
- package/README.md +241 -11
- package/dist/cli/run.d.ts +1 -1
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +2 -2
- package/dist/cli/run.js.map +1 -1
- package/dist/contract.d.ts +48 -2
- package/dist/contract.d.ts.map +1 -1
- package/dist/index.d.ts +88 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -4
- package/dist/index.js.map +1 -1
- package/dist/jobs/index.d.ts +2 -0
- package/dist/jobs/index.d.ts.map +1 -1
- package/dist/jobs/index.js +5 -2
- package/dist/jobs/index.js.map +1 -1
- package/dist/jobs/operation-state.d.ts +58 -0
- package/dist/jobs/operation-state.d.ts.map +1 -0
- package/dist/jobs/operation-state.js +68 -0
- package/dist/jobs/operation-state.js.map +1 -0
- package/dist/jobs/operation-tasks.d.ts +68 -0
- package/dist/jobs/operation-tasks.d.ts.map +1 -0
- package/dist/jobs/operation-tasks.js +163 -0
- package/dist/jobs/operation-tasks.js.map +1 -0
- package/dist/jobs/operations.d.ts +89 -0
- package/dist/jobs/operations.d.ts.map +1 -0
- package/dist/jobs/operations.js +131 -0
- package/dist/jobs/operations.js.map +1 -0
- package/dist/messages/en.d.ts +55 -0
- package/dist/messages/en.d.ts.map +1 -1
- package/dist/messages/en.js +55 -0
- package/dist/messages/en.js.map +1 -1
- package/dist/messages/index.d.ts +54 -0
- package/dist/messages/index.d.ts.map +1 -1
- package/dist/next/issue-token.d.ts +11 -2
- package/dist/next/issue-token.d.ts.map +1 -1
- package/dist/next/issue-token.js +56 -7
- package/dist/next/issue-token.js.map +1 -1
- package/dist/next/token-manager-props.d.ts +3 -2
- package/dist/next/token-manager-props.d.ts.map +1 -1
- package/dist/next/token-manager-props.js +12 -2
- package/dist/next/token-manager-props.js.map +1 -1
- package/dist/options.d.ts +47 -0
- package/dist/options.d.ts.map +1 -1
- package/dist/options.js +53 -0
- package/dist/options.js.map +1 -1
- package/dist/schema.d.ts +214 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +21 -3
- package/dist/schema.js.map +1 -1
- package/dist/server/api-tokens.d.ts +64 -0
- package/dist/server/api-tokens.d.ts.map +1 -0
- package/dist/server/api-tokens.js +176 -0
- package/dist/server/api-tokens.js.map +1 -0
- package/dist/server/confirmation.d.ts +69 -0
- package/dist/server/confirmation.d.ts.map +1 -0
- package/dist/server/confirmation.js +220 -0
- package/dist/server/confirmation.js.map +1 -0
- package/dist/server/health.d.ts.map +1 -1
- package/dist/server/health.js +3 -1
- package/dist/server/health.js.map +1 -1
- package/dist/server/index.d.ts +4 -2
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +3 -1
- package/dist/server/index.js.map +1 -1
- package/dist/server/privacy.d.ts +4 -0
- package/dist/server/privacy.d.ts.map +1 -1
- package/dist/server/privacy.js +5 -1
- package/dist/server/privacy.js.map +1 -1
- package/dist/server/prune.d.ts +5 -1
- package/dist/server/prune.d.ts.map +1 -1
- package/dist/server/prune.js +14 -4
- package/dist/server/prune.js.map +1 -1
- package/dist/server/strict-input.d.ts +3 -1
- package/dist/server/strict-input.d.ts.map +1 -1
- package/dist/server/strict-input.js +54 -8
- package/dist/server/strict-input.js.map +1 -1
- package/dist/server/tokens.d.ts +46 -11
- package/dist/server/tokens.d.ts.map +1 -1
- package/dist/server/tokens.js +46 -24
- package/dist/server/tokens.js.map +1 -1
- package/dist/server/tools.d.ts +8 -0
- package/dist/server/tools.d.ts.map +1 -1
- package/dist/server/tools.js +1 -0
- package/dist/server/tools.js.map +1 -1
- package/dist/tokens/index.d.ts +6 -0
- package/dist/tokens/index.d.ts.map +1 -0
- package/dist/tokens/index.js +8 -0
- package/dist/tokens/index.js.map +1 -0
- package/dist/ui/index.d.ts +1 -1
- package/dist/ui/index.d.ts.map +1 -1
- package/dist/ui/index.js.map +1 -1
- package/dist/ui/token-manager.d.ts +18 -2
- package/dist/ui/token-manager.d.ts.map +1 -1
- package/dist/ui/token-manager.js +28 -3
- package/dist/ui/token-manager.js.map +1 -1
- package/migrations/0004_create_used_confirmations.sql +13 -0
- package/migrations/0005_add_api_tokens.sql +15 -0
- package/migrations/0006_create_operation_tasks.sql +18 -0
- package/module.json +9 -3
- package/package.json +7 -2
- package/src/cli/run.ts +2 -2
- package/src/contract.ts +60 -2
- package/src/index.ts +31 -3
- package/src/jobs/index.ts +43 -2
- package/src/jobs/operation-state.ts +119 -0
- package/src/jobs/operation-tasks.ts +234 -0
- package/src/jobs/operations.ts +258 -0
- package/src/messages/en.ts +56 -0
- package/src/next/issue-token.ts +64 -9
- package/src/next/token-manager-props.ts +15 -4
- package/src/options.ts +62 -0
- package/src/schema.ts +23 -3
- package/src/server/api-tokens.ts +224 -0
- package/src/server/confirmation.ts +316 -0
- package/src/server/health.ts +3 -1
- package/src/server/index.ts +32 -2
- package/src/server/privacy.ts +9 -1
- package/src/server/prune.ts +17 -4
- package/src/server/strict-input.ts +62 -13
- package/src/server/tokens.ts +73 -27
- package/src/server/tools.ts +9 -0
- package/src/tokens/index.ts +22 -0
- package/src/ui/index.ts +1 -0
- package/src/ui/token-manager.tsx +140 -3
package/src/server/prune.ts
CHANGED
|
@@ -1,14 +1,27 @@
|
|
|
1
|
-
// The module's scheduled cleanup: expired access tokens
|
|
2
|
-
// one call for a daily job (`softure-mcp prune`, a deploy `maintain` hook) instead of inline on requests.
|
|
1
|
+
// The module's scheduled cleanup: expired access tokens, dead OAuth records of every account, used
|
|
2
|
+
// confirmation tokens and MCP tasks past their expiry, in one call for a daily job (`softure-mcp prune`, a deploy `maintain` hook) instead of inline on requests.
|
|
3
|
+
import { lte } from "drizzle-orm";
|
|
4
|
+
import { operationTasks } from "../schema.js";
|
|
5
|
+
import { pruneUsedConfirmations } from "./confirmation.js";
|
|
3
6
|
import { pruneOAuthRecords, type PrunedOAuthRecords } from "./oauth.js";
|
|
4
7
|
import { pruneAccessTokens, type McpAccessContext } from "./tokens.js";
|
|
5
8
|
|
|
6
9
|
export interface PrunedMcpAccessRecords extends PrunedOAuthRecords {
|
|
7
10
|
readonly accessTokens: number;
|
|
11
|
+
readonly usedConfirmations: number;
|
|
12
|
+
readonly operationTasks: number;
|
|
8
13
|
}
|
|
9
14
|
|
|
10
|
-
/** Runs `pruneAccessTokens`, then `
|
|
15
|
+
/** Runs `pruneAccessTokens`, `pruneOAuthRecords`, `pruneUsedConfirmations`, then `pruneOperationTasks`, and counts what each removed. */
|
|
11
16
|
export async function pruneMcpAccess(ctx: McpAccessContext): Promise<PrunedMcpAccessRecords> {
|
|
12
17
|
const accessTokens = await pruneAccessTokens(ctx);
|
|
13
|
-
|
|
18
|
+
const oauth = await pruneOAuthRecords(ctx);
|
|
19
|
+
const usedConfirmations = await pruneUsedConfirmations(ctx);
|
|
20
|
+
return { accessTokens, ...oauth, usedConfirmations, operationTasks: await pruneOperationTasks(ctx) };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Deletes the MCP tasks past their expiry (#977); the operations' jobs stay with `@softure-ai/jobs`. */
|
|
24
|
+
export async function pruneOperationTasks(ctx: McpAccessContext): Promise<number> {
|
|
25
|
+
const removed = await ctx.db.delete(operationTasks).where(lte(operationTasks.expiresAt, ctx.clock.now())).returning({ taskId: operationTasks.taskId });
|
|
26
|
+
return removed.length;
|
|
14
27
|
}
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// `withStrictToolInput` rewrites every input schema once, at registration: each object refuses a field
|
|
4
4
|
// it does not know and names the ones it accepts, a field sent under its old name is refused with the
|
|
5
5
|
// stable code `input.field_renamed`, and boolean and number fields also take their exact text (a client
|
|
6
|
-
// on an older tool list sends a field it does not know as text).
|
|
7
|
-
// runs, so a refused call saves nothing.
|
|
6
|
+
// on an older tool list sends a field it does not know as text). A tool registered without an input schema
|
|
7
|
+
// gets the strict empty object. The SDK validates before the handler runs, so a refused call saves nothing.
|
|
8
8
|
//
|
|
9
9
|
// const server = withStrictToolInput(new McpServer({ name: "acme", version }), { renamedFields: { notes: "note" } });
|
|
10
10
|
import type { McpServer } from "@modelcontextprotocol/server";
|
|
@@ -16,9 +16,12 @@ import { en } from "../messages/en.js";
|
|
|
16
16
|
/** The texts of a refused field: `toolInput` of the module dictionary (`getMcpAccessMessages(config).toolInput`). */
|
|
17
17
|
export type StrictToolInputMessages = typeof en.toolInput;
|
|
18
18
|
|
|
19
|
+
/** Old field name → new field name, as a map or a rule (`undefined`: not renamed). */
|
|
20
|
+
export type RenamedFields = Readonly<Record<string, string>> | ((field: string) => string | undefined);
|
|
21
|
+
|
|
19
22
|
export interface StrictToolInputOptions {
|
|
20
23
|
/** Old field name → new field name, refused as `input.field_renamed` wherever an object accepts the new name. */
|
|
21
|
-
readonly renamedFields?:
|
|
24
|
+
readonly renamedFields?: RenamedFields;
|
|
22
25
|
/** Boolean fields also take `"true"`/`"false"` and number fields a number written as text. Default: `true`. */
|
|
23
26
|
readonly coerceTextScalars?: boolean;
|
|
24
27
|
/** Default: the module's English dictionary. */
|
|
@@ -32,14 +35,20 @@ const strictServers = new WeakSet<McpServer>();
|
|
|
32
35
|
|
|
33
36
|
type ToolConfig = Readonly<Record<string, unknown>> & { readonly inputSchema?: unknown };
|
|
34
37
|
type RegisterTool = (name: string, config: ToolConfig, callback: unknown) => unknown;
|
|
38
|
+
type ContextCallback = (ctx: unknown) => unknown;
|
|
35
39
|
|
|
36
40
|
/** Makes every tool registered on the server from now on strict; wrapping a server twice changes nothing. */
|
|
37
41
|
export function withStrictToolInput<Server extends McpServer>(server: Server, options: StrictToolInputOptions = {}): Server {
|
|
38
42
|
if (strictServers.has(server)) return server;
|
|
39
43
|
// The SDK's overloads only differ in how the schema is typed; at runtime it is one method.
|
|
40
44
|
const register = server.registerTool.bind(server) as unknown as RegisterTool;
|
|
41
|
-
const registerStrict: RegisterTool = (name, config, callback) =>
|
|
42
|
-
|
|
45
|
+
const registerStrict: RegisterTool = (name, config, callback) => {
|
|
46
|
+
if (config.inputSchema !== undefined) return register(name, { ...config, inputSchema: toStrictToolInput(config.inputSchema, options) }, callback);
|
|
47
|
+
// Without a schema the SDK skips validation and calls `callback(ctx)`; with the strict empty object it calls
|
|
48
|
+
// `callback(args, ctx)`, so the callback keeps receiving the context only.
|
|
49
|
+
const contextCallback = callback as ContextCallback;
|
|
50
|
+
return register(name, { ...config, inputSchema: toStrictToolInput(z.object({}), options) }, (_args: unknown, ctx: unknown) => contextCallback(ctx));
|
|
51
|
+
};
|
|
43
52
|
Object.defineProperty(server, "registerTool", { value: registerStrict, configurable: true, writable: true });
|
|
44
53
|
strictServers.add(server);
|
|
45
54
|
return server;
|
|
@@ -50,7 +59,7 @@ export function toStrictToolInput(schema: unknown, options: StrictToolInputOptio
|
|
|
50
59
|
const zodSchema = isZodSchema(schema) ? schema : isRawShape(schema) ? z.object(schema) : null;
|
|
51
60
|
if (zodSchema === null) return schema;
|
|
52
61
|
const rewriter = new StrictRewriter({
|
|
53
|
-
|
|
62
|
+
renameField: toRenameRule(options.renamedFields ?? {}),
|
|
54
63
|
coerceTextScalars: options.coerceTextScalars ?? true,
|
|
55
64
|
messages: options.messages ?? en.toolInput,
|
|
56
65
|
});
|
|
@@ -66,7 +75,19 @@ function isRawShape(value: unknown): value is Record<string, core.$ZodType> {
|
|
|
66
75
|
return Object.values(value).every(isZodSchema);
|
|
67
76
|
}
|
|
68
77
|
|
|
69
|
-
|
|
78
|
+
function toRenameRule(renamedFields: RenamedFields): (field: string) => string | undefined {
|
|
79
|
+
if (typeof renamedFields === "function") return renamedFields;
|
|
80
|
+
return (field) => (Object.hasOwn(renamedFields, field) ? renamedFields[field] : undefined);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface RewriteSettings {
|
|
84
|
+
readonly renameField: (field: string) => string | undefined;
|
|
85
|
+
readonly coerceTextScalars: boolean;
|
|
86
|
+
readonly messages: StrictToolInputMessages;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Wrappers that keep the value's kind: a chain of them around a scalar takes the text step around the whole chain. */
|
|
90
|
+
const WRAPPER_TYPES = new Set(["optional", "nullable", "default", "prefault", "nonoptional", "readonly", "catch"]);
|
|
70
91
|
|
|
71
92
|
/** A schema node as the rewriter reads it: its kind and the parts it rewrites. */
|
|
72
93
|
type AnyDef = core.$ZodTypeDef & Record<string, unknown>;
|
|
@@ -98,8 +119,13 @@ class StrictRewriter {
|
|
|
98
119
|
case "prefault":
|
|
99
120
|
case "nonoptional":
|
|
100
121
|
case "readonly":
|
|
101
|
-
case "catch":
|
|
122
|
+
case "catch": {
|
|
123
|
+
// Outside the chain, zod still lists its `default` (it drops one on a transforming node) and the field's
|
|
124
|
+
// optionality is the chain's own; the step passes `undefined` and `null` through to it.
|
|
125
|
+
const reader = this.findTextReader(schema);
|
|
126
|
+
if (reader !== undefined) return withTextStep(reader, schema);
|
|
102
127
|
return this.cloneWith(schema, { innerType: this.rewrite(def.innerType as core.$ZodType) });
|
|
128
|
+
}
|
|
103
129
|
case "union":
|
|
104
130
|
return this.cloneWith(schema, { options: (def.options as core.$ZodType[]).map((option) => this.rewrite(option)) });
|
|
105
131
|
case "intersection":
|
|
@@ -118,14 +144,26 @@ class StrictRewriter {
|
|
|
118
144
|
return this.cloneWith(schema, { getter: () => this.rewrite(getter()) });
|
|
119
145
|
}
|
|
120
146
|
case "boolean":
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
return
|
|
147
|
+
case "number": {
|
|
148
|
+
const reader = this.findTextReader(schema);
|
|
149
|
+
return reader === undefined ? schema : withTextStep(reader, schema);
|
|
150
|
+
}
|
|
124
151
|
default:
|
|
125
152
|
return schema;
|
|
126
153
|
}
|
|
127
154
|
}
|
|
128
155
|
|
|
156
|
+
/** The text reader for a boolean or number, seen through its wrapper chain; none when text is not taken. */
|
|
157
|
+
private findTextReader(schema: core.$ZodType): ((value: unknown) => unknown) | undefined {
|
|
158
|
+
if (!this.settings.coerceTextScalars) return undefined;
|
|
159
|
+
let def = schema._zod.def as AnyDef;
|
|
160
|
+
while (WRAPPER_TYPES.has(def.type)) def = (def.innerType as core.$ZodType)._zod.def as AnyDef;
|
|
161
|
+
if (def.coerce === true) return undefined;
|
|
162
|
+
if (def.type === "boolean") return readBooleanText;
|
|
163
|
+
if (def.type === "number") return readNumberText;
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
|
|
129
167
|
/** Only the input side: `in`, or `out` after a preprocess (whose `in` is the transform). */
|
|
130
168
|
private rewritePipe(schema: core.$ZodType, def: AnyDef): core.$ZodType {
|
|
131
169
|
const input = def.in as core.$ZodType;
|
|
@@ -162,11 +200,11 @@ class StrictRewriter {
|
|
|
162
200
|
/** The text for an object's unknown fields: renamed ones first, then the rest with the fields it accepts. */
|
|
163
201
|
function describeUnknownFields(issue: core.$ZodRawIssue, accepted: readonly string[], settings: RewriteSettings): string | undefined {
|
|
164
202
|
if (issue.code !== "unrecognized_keys") return undefined;
|
|
165
|
-
const { messages,
|
|
203
|
+
const { messages, renameField } = settings;
|
|
166
204
|
const renamed: string[] = [];
|
|
167
205
|
const unknown: string[] = [];
|
|
168
206
|
for (const field of issue.keys) {
|
|
169
|
-
const newName =
|
|
207
|
+
const newName = renameField(field);
|
|
170
208
|
if (newName !== undefined && accepted.includes(newName)) renamed.push(formatMessage(messages.fieldRenamed, { field, renamed: newName }));
|
|
171
209
|
else unknown.push(field);
|
|
172
210
|
}
|
|
@@ -180,6 +218,17 @@ function quoteAll(names: readonly string[]): string {
|
|
|
180
218
|
return names.map((name) => JSON.stringify(name)).join(", ");
|
|
181
219
|
}
|
|
182
220
|
|
|
221
|
+
/**
|
|
222
|
+
* `schema` behind a text step. zod gives every transform an optional input, so a step would make a required field
|
|
223
|
+
* look optional in the listed JSON Schema inside `nullable` or a union; it takes the optionality of the schema it
|
|
224
|
+
* wraps instead. That changes no parse: the step passes `undefined` on, and the schema refuses it as before.
|
|
225
|
+
*/
|
|
226
|
+
function withTextStep(reader: (value: unknown) => unknown, schema: core.$ZodType): core.$ZodType {
|
|
227
|
+
const step = z.preprocess(reader, schema as z.ZodType);
|
|
228
|
+
Object.defineProperty(step._zod, "optin", { value: schema._zod.optin, configurable: true, writable: true });
|
|
229
|
+
return step;
|
|
230
|
+
}
|
|
231
|
+
|
|
183
232
|
function readBooleanText(value: unknown): unknown {
|
|
184
233
|
if (value === "true") return true;
|
|
185
234
|
if (value === "false") return false;
|
package/src/server/tokens.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
// Access tokens (an adopting app's token code, generalised).
|
|
2
|
-
// bytes; the database holds only its sha256, so a leaked row cannot be replayed. The plaintext
|
|
1
|
+
// Access tokens (an adopting app's token code, generalised). An MCP token is `sftmcp_` and 32 random
|
|
2
|
+
// bytes, an API token (`api-tokens.ts`) the app's prefix and 32 random bytes; the database holds only its sha256, so a leaked row cannot be replayed. The plaintext
|
|
3
3
|
// leaves this file once, in the result of `issueAccessToken` (or of an OAuth exchange, `oauth.ts`).
|
|
4
4
|
import { createHash, randomBytes } from "node:crypto";
|
|
5
5
|
import { err, errorLogLabel, ok, type Err, type ModuleContext, type Ok } from "@softure-ai/core";
|
|
6
6
|
import type { Queryable } from "@softure-ai/db";
|
|
7
7
|
import { and, count, desc, eq, gt, isNull, lt, lte, or, sql } from "drizzle-orm";
|
|
8
|
-
import type { AccessTokenView } from "../contract.js";
|
|
8
|
+
import type { AccessTokenKind, AccessTokenView } from "../contract.js";
|
|
9
9
|
import { MAX_PRESENTED_TOKEN_LENGTH, MAX_TOKEN_NAME_LENGTH, type McpAccessOptions } from "../options.js";
|
|
10
10
|
import { accessTokens, oauthGrants } from "../schema.js";
|
|
11
11
|
import { getMcpAccessOptions } from "./options.js";
|
|
@@ -70,22 +70,46 @@ export type IssueAccessTokenResult =
|
|
|
70
70
|
| Err<"mcp-access.name_required" | "mcp-access.name_too_long" | "mcp-access.token_limit_reached">;
|
|
71
71
|
|
|
72
72
|
/**
|
|
73
|
-
* Issues
|
|
74
|
-
* that holds a per-user advisory lock, so two parallel requests cannot both take the last
|
|
75
|
-
* The owner's expired tokens are deleted first: they would otherwise count against the limit.
|
|
73
|
+
* Issues an MCP token for its owner. The per-account limit counts unexpired tokens of both kinds, in one
|
|
74
|
+
* transaction that holds a per-user advisory lock, so two parallel requests cannot both take the last
|
|
75
|
+
* place. The owner's expired tokens are deleted first: they would otherwise count against the limit.
|
|
76
76
|
* Database failures propagate.
|
|
77
77
|
*/
|
|
78
78
|
export async function issueAccessToken(ctx: McpAccessContext, input: IssueAccessTokenInput): Promise<IssueAccessTokenResult> {
|
|
79
|
+
const options = getMcpAccessOptions(ctx.config);
|
|
80
|
+
const canWrite = input.canWrite && options.allowWrites;
|
|
81
|
+
const result = await insertToken(ctx, { userId: input.userId, name: input.name, kind: "mcp", canWrite, scopes: [], secret: createSecret(ACCESS_TOKEN_PREFIX) });
|
|
82
|
+
if (!result.ok) return result;
|
|
83
|
+
const { id, token, name, expiresAt } = result.value;
|
|
84
|
+
return ok({ id, token, name, canWrite, expiresAt });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** What `insertToken` stores: the new secret with its hash, write access and scopes are already decided. */
|
|
88
|
+
export interface TokenInsert {
|
|
89
|
+
readonly userId: string;
|
|
90
|
+
readonly name: string;
|
|
91
|
+
readonly kind: AccessTokenKind;
|
|
92
|
+
readonly canWrite: boolean;
|
|
93
|
+
readonly scopes: readonly string[];
|
|
94
|
+
/** The plaintext and the hash the lookup will find it by (`createSecret`, or the API token's own). */
|
|
95
|
+
readonly secret: { readonly secret: string; readonly secretHash: string };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export type InsertTokenResult =
|
|
99
|
+
| Ok<{ readonly id: string; readonly token: string; readonly name: string; readonly expiresAt: Date }>
|
|
100
|
+
| Err<"mcp-access.name_required" | "mcp-access.name_too_long" | "mcp-access.token_limit_reached">;
|
|
101
|
+
|
|
102
|
+
/** Stores a new token of either kind under the owner's lock and limit; the plaintext is returned once. */
|
|
103
|
+
export async function insertToken(ctx: McpAccessContext, input: TokenInsert): Promise<InsertTokenResult> {
|
|
79
104
|
const name = input.name.trim();
|
|
80
105
|
if (name === "") return err("mcp-access.name_required");
|
|
81
106
|
if (name.length > MAX_TOKEN_NAME_LENGTH) return err("mcp-access.name_too_long");
|
|
82
107
|
|
|
83
108
|
const options = getMcpAccessOptions(ctx.config);
|
|
84
|
-
const canWrite = input.canWrite && options.allowWrites;
|
|
85
109
|
const now = ctx.clock.now();
|
|
86
110
|
const expiresAt = new Date(now.getTime() + options.tokenLifetimeDays * DAY_MS);
|
|
87
111
|
|
|
88
|
-
return ctx.db.transaction(async (tx): Promise<
|
|
112
|
+
return ctx.db.transaction(async (tx): Promise<InsertTokenResult> => {
|
|
89
113
|
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`mcp-access:${input.userId}`}, 0))`);
|
|
90
114
|
await tx.delete(accessTokens).where(and(eq(accessTokens.userId, input.userId), lte(accessTokens.expiresAt, now)));
|
|
91
115
|
// OAuth access tokens belong to their grant and rotate every hour: they do not take places.
|
|
@@ -95,25 +119,27 @@ export async function issueAccessToken(ctx: McpAccessContext, input: IssueAccess
|
|
|
95
119
|
.where(and(eq(accessTokens.userId, input.userId), isNull(accessTokens.grantId)));
|
|
96
120
|
if ((held?.total ?? 0) >= options.maxTokensPerUser) return err("mcp-access.token_limit_reached");
|
|
97
121
|
|
|
98
|
-
const { secret: token, secretHash: tokenHash } =
|
|
122
|
+
const { secret: token, secretHash: tokenHash } = input.secret;
|
|
99
123
|
const [row] = await tx
|
|
100
124
|
.insert(accessTokens)
|
|
101
|
-
.values({ userId: input.userId, name, tokenHash, canWrite, createdAt: now, expiresAt })
|
|
125
|
+
.values({ userId: input.userId, name, tokenHash, canWrite: input.canWrite, createdAt: now, expiresAt, kind: input.kind, scopes: [...input.scopes] })
|
|
102
126
|
.returning();
|
|
103
127
|
if (row === undefined) throw new Error("@softure-ai/mcp-access: inserting a token returned no row");
|
|
104
|
-
return ok({ id: row.id, token, name,
|
|
128
|
+
return ok({ id: row.id, token, name, expiresAt });
|
|
105
129
|
});
|
|
106
130
|
}
|
|
107
131
|
|
|
108
132
|
/**
|
|
109
|
-
* The owner's hand-issued tokens, newest first, expired ones included (the list marks
|
|
110
|
-
* OAuth access tokens are left out: the page shows their grants instead.
|
|
133
|
+
* The owner's hand-issued tokens of both kinds, newest first, expired ones included (the list marks
|
|
134
|
+
* them). OAuth access tokens are left out: the page shows their grants instead.
|
|
111
135
|
*/
|
|
112
136
|
export async function listAccessTokens(ctx: McpAccessContext, userId: string): Promise<AccessTokenView[]> {
|
|
113
137
|
return ctx.db
|
|
114
138
|
.select({
|
|
115
139
|
id: accessTokens.id,
|
|
116
140
|
name: accessTokens.name,
|
|
141
|
+
kind: accessTokens.kind,
|
|
142
|
+
scopes: accessTokens.scopes,
|
|
117
143
|
canWrite: accessTokens.canWrite,
|
|
118
144
|
createdAt: accessTokens.createdAt,
|
|
119
145
|
expiresAt: accessTokens.expiresAt,
|
|
@@ -170,14 +196,40 @@ export async function findTokenClientName(ctx: McpAccessContext, tokenId: string
|
|
|
170
196
|
}
|
|
171
197
|
|
|
172
198
|
/**
|
|
173
|
-
* The identity behind
|
|
174
|
-
* tell which, so a guess learns nothing. The expiry sits in the query, so no
|
|
175
|
-
* it. Records the use (at most once a minute per token); a failed record is
|
|
176
|
-
* fail the request. Database failures of the lookup propagate.
|
|
199
|
+
* The identity behind an MCP token, or null for anything unknown, revoked or expired, and for an API
|
|
200
|
+
* token: the caller cannot tell which, so a guess learns nothing. The expiry sits in the query, so no
|
|
201
|
+
* code path can forget it. Records the use (at most once a minute per token); a failed record is
|
|
202
|
+
* logged and does not fail the request. Database failures of the lookup propagate.
|
|
177
203
|
*/
|
|
178
204
|
export async function verifyAccessToken(ctx: McpAccessContext, token: string): Promise<VerifiedAccessToken | null> {
|
|
179
205
|
const options = getMcpAccessOptions(ctx.config);
|
|
180
206
|
if (!isAcceptedTokenShape(options, token)) return null;
|
|
207
|
+
const row = await findValidToken(ctx, hashAccessToken(token), "mcp");
|
|
208
|
+
if (row === null) return null;
|
|
209
|
+
return {
|
|
210
|
+
tokenId: row.id,
|
|
211
|
+
userId: row.userId,
|
|
212
|
+
canWrite: row.canWrite && options.allowWrites,
|
|
213
|
+
expiresAt: row.expiresAt,
|
|
214
|
+
grantId: row.grantId,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** A stored, unexpired token of one kind, found by the hash of its plaintext. */
|
|
219
|
+
export interface ValidTokenRow {
|
|
220
|
+
readonly id: string;
|
|
221
|
+
readonly userId: string;
|
|
222
|
+
readonly canWrite: boolean;
|
|
223
|
+
readonly expiresAt: Date;
|
|
224
|
+
readonly grantId: string | null;
|
|
225
|
+
readonly scopes: readonly string[];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The unexpired token of `kind` stored under this hash, or null, after recording its use. The caller
|
|
230
|
+
* checks the shape first, so no lookup runs for a value that cannot be a token.
|
|
231
|
+
*/
|
|
232
|
+
export async function findValidToken(ctx: McpAccessContext, tokenHash: string, kind: AccessTokenKind): Promise<ValidTokenRow | null> {
|
|
181
233
|
const now = ctx.clock.now();
|
|
182
234
|
const [row] = await ctx.db
|
|
183
235
|
.select({
|
|
@@ -186,19 +238,13 @@ export async function verifyAccessToken(ctx: McpAccessContext, token: string): P
|
|
|
186
238
|
canWrite: accessTokens.canWrite,
|
|
187
239
|
expiresAt: accessTokens.expiresAt,
|
|
188
240
|
grantId: accessTokens.grantId,
|
|
241
|
+
scopes: accessTokens.scopes,
|
|
189
242
|
})
|
|
190
243
|
.from(accessTokens)
|
|
191
|
-
.where(and(eq(accessTokens.tokenHash,
|
|
244
|
+
.where(and(eq(accessTokens.tokenHash, tokenHash), eq(accessTokens.kind, kind), gt(accessTokens.expiresAt, now)));
|
|
192
245
|
if (row === undefined) return null;
|
|
193
|
-
|
|
194
246
|
await recordUse(ctx, row, now);
|
|
195
|
-
return
|
|
196
|
-
tokenId: row.id,
|
|
197
|
-
userId: row.userId,
|
|
198
|
-
canWrite: row.canWrite && options.allowWrites,
|
|
199
|
-
expiresAt: row.expiresAt,
|
|
200
|
-
grantId: row.grantId,
|
|
201
|
-
};
|
|
247
|
+
return row;
|
|
202
248
|
}
|
|
203
249
|
|
|
204
250
|
/** Records the use on the token and, for an OAuth token, on its grant (the connected apps list). */
|
|
@@ -222,7 +268,7 @@ async function recordUse(ctx: McpAccessContext, token: { readonly id: string; re
|
|
|
222
268
|
}
|
|
223
269
|
|
|
224
270
|
/**
|
|
225
|
-
* Deletes every expired token, of every account, hand-issued and OAuth alike. Issuing already
|
|
271
|
+
* Deletes every expired token, of every account and kind, hand-issued and OAuth alike. Issuing already
|
|
226
272
|
* removes the issuer's own; an app may call this from a scheduled job so abandoned accounts do not
|
|
227
273
|
* keep dead rows (with `pruneOAuthRecords` for codes, grants and clients).
|
|
228
274
|
*/
|
package/src/server/tools.ts
CHANGED
|
@@ -19,6 +19,8 @@ export interface ToolErrorOptions {
|
|
|
19
19
|
readonly caller?: ToolCaller;
|
|
20
20
|
/** The tool's arguments, for the record: only their names are read, never their values. */
|
|
21
21
|
readonly args?: Readonly<Record<string, unknown>>;
|
|
22
|
+
/** Whether the call ran on the person's confirmation, for the record (`withConfirmation` sets it). */
|
|
23
|
+
readonly confirmed?: boolean;
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
/** The client behind a call: the token's id (`McpServerIdentity.tokenId`) and its name (`findTokenClientName`). */
|
|
@@ -44,6 +46,12 @@ export interface ToolCallRecord {
|
|
|
44
46
|
readonly tokenId: string | null;
|
|
45
47
|
readonly clientName: string | null;
|
|
46
48
|
readonly argumentNames: readonly string[];
|
|
49
|
+
/**
|
|
50
|
+
* For a tool wrapped in `withConfirmation`: `true` when the call carried a valid confirmation and did the work,
|
|
51
|
+
* `false` when it answered an estimate, was refused, or ran without asking (`runWithoutConfirmationWhen`). `null`
|
|
52
|
+
* for any other tool.
|
|
53
|
+
*/
|
|
54
|
+
readonly confirmed: boolean | null;
|
|
47
55
|
readonly outcome: ToolCallOutcome;
|
|
48
56
|
readonly startedAt: Date;
|
|
49
57
|
readonly durationMs: number;
|
|
@@ -96,6 +104,7 @@ async function reportToolCall(options: ToolErrorOptions, outcome: ToolCallOutcom
|
|
|
96
104
|
tokenId: options.caller?.tokenId ?? null,
|
|
97
105
|
clientName: options.caller?.clientName ?? null,
|
|
98
106
|
argumentNames: Object.keys(options.args ?? {}),
|
|
107
|
+
confirmed: options.confirmed ?? null,
|
|
99
108
|
outcome,
|
|
100
109
|
startedAt: new Date(startedAt),
|
|
101
110
|
durationMs: Date.now() - startedAt,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Personal access tokens for an app's plain HTTP routes, without the MCP server (#961): nothing this
|
|
2
|
+
// entry loads imports `@modelcontextprotocol/*` (tests/api-tokens-961.test.ts walks the graph). A route
|
|
3
|
+
// handler checks a request with `requireApiToken` and answers a refusal with `apiTokenErrorResponse`.
|
|
4
|
+
export {
|
|
5
|
+
API_RATE_LIMIT_BUCKET,
|
|
6
|
+
API_TOKEN_RATE_LIMIT_BUCKET,
|
|
7
|
+
apiTokenErrorResponse,
|
|
8
|
+
getApiTokenOptions,
|
|
9
|
+
isApiTokenShape,
|
|
10
|
+
issueApiToken,
|
|
11
|
+
requireApiToken,
|
|
12
|
+
verifyApiToken,
|
|
13
|
+
type IssueApiTokenInput,
|
|
14
|
+
type IssueApiTokenResult,
|
|
15
|
+
type IssuedApiToken,
|
|
16
|
+
type RequireApiTokenOptions,
|
|
17
|
+
type VerifiedApiToken,
|
|
18
|
+
} from "../server/api-tokens.js";
|
|
19
|
+
export { createMcpAccessContext } from "../server/context.js";
|
|
20
|
+
export { isWritingEnabled } from "../server/writes-switch.js";
|
|
21
|
+
export { hashAccessToken, listAccessTokens, pruneAccessTokens, revokeAccessToken, type McpAccessContext, type RevokeAccessTokenInput } from "../server/tokens.js";
|
|
22
|
+
export type { AccessTokenKind, AccessTokenView, ApiTokenErrorCode, ApiTokenIdentity, ApiTokenRefusal } from "../contract.js";
|
package/src/ui/index.ts
CHANGED
package/src/ui/token-manager.tsx
CHANGED
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
import { formatMessage, type Locale } from "@softure-ai/core";
|
|
4
4
|
import { Button, ButtonLink, Checkbox, type ClassNames, createSlotClassGetter, FormError, TextField } from "@softure-ai/ui";
|
|
5
5
|
import { useActionState, useEffect, useRef, useState } from "react";
|
|
6
|
-
import type { IssuedToken, IssueTokenFormState, RevokeGrantFormState, RevokeTokenFormState } from "../contract.js";
|
|
6
|
+
import type { IssuedApiTokenView, IssuedToken, IssueTokenFormState, RevokeGrantFormState, RevokeTokenFormState } from "../contract.js";
|
|
7
7
|
import { getTokenErrorMessage, type McpAccessMessages } from "../messages/index.js";
|
|
8
8
|
import { MAX_TOKEN_NAME_LENGTH } from "../options.js";
|
|
9
9
|
|
|
10
10
|
// The token page's client part: the tool catalog, the issue form, the token just issued with its
|
|
11
|
-
// setup snippets, the
|
|
11
|
+
// setup snippets, (with API scopes) the app's scopes and a second form for tokens for other apps,
|
|
12
|
+
// the owner's tokens of both kinds with a revoke button each, and, with OAuth on, the apps
|
|
12
13
|
// connected through OAuth with a disconnect button each. Both forms submit to their
|
|
13
14
|
// server actions through `useActionState`. The plaintext lives only in the issue form's state:
|
|
14
15
|
// "Done" or leaving the page drops it. Focus follows the one-time token: its heading takes focus
|
|
@@ -29,11 +30,23 @@ export interface TokenManagerTool {
|
|
|
29
30
|
readonly accessText: string;
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
/** One scope the app declares for API tokens, prepared on the server. */
|
|
34
|
+
export interface TokenManagerApiScope {
|
|
35
|
+
readonly name: string;
|
|
36
|
+
readonly description: string;
|
|
37
|
+
/** "Reads", "Changes data", or that writes are off, already in the app's copy. */
|
|
38
|
+
readonly accessText: string;
|
|
39
|
+
/** Offered on the form: a read scope, or a write scope while the app allows writes. */
|
|
40
|
+
readonly isOffered: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
32
43
|
/** One of the owner's tokens, prepared on the server. */
|
|
33
44
|
export interface TokenManagerRow {
|
|
34
45
|
readonly id: string;
|
|
35
46
|
readonly name: string;
|
|
36
|
-
/**
|
|
47
|
+
/** "AI assistant" or "Other apps", while the app offers both kinds. */
|
|
48
|
+
readonly kindText?: string;
|
|
49
|
+
/** Read only, or read and change, already in the app's copy; an API token's scopes. */
|
|
37
50
|
readonly scopeText: string;
|
|
38
51
|
/** Active, expiring in n days, or expired. */
|
|
39
52
|
readonly statusText: string;
|
|
@@ -65,6 +78,11 @@ export interface TokenManagerProps {
|
|
|
65
78
|
readonly grants?: readonly TokenManagerGrant[];
|
|
66
79
|
/** Disconnects one app; needed with `grants`. */
|
|
67
80
|
readonly revokeGrantAction?: RevokeGrantAction;
|
|
81
|
+
/**
|
|
82
|
+
* The app's API scopes (`apiTokens`); with them the page lists them and offers a second form for tokens
|
|
83
|
+
* for other apps, submitted to `issueAction` with `kind=api`. Leave out, and the page offers MCP tokens only.
|
|
84
|
+
*/
|
|
85
|
+
readonly apiScopes?: readonly TokenManagerApiScope[];
|
|
68
86
|
readonly messages: McpAccessMessages;
|
|
69
87
|
/** Locale of the built-in copy of the ui primitives. */
|
|
70
88
|
readonly locale?: Locale;
|
|
@@ -211,6 +229,103 @@ function IssueForm({
|
|
|
211
229
|
);
|
|
212
230
|
}
|
|
213
231
|
|
|
232
|
+
function IssuedApiTokenPanel({
|
|
233
|
+
issued,
|
|
234
|
+
onDone,
|
|
235
|
+
messages,
|
|
236
|
+
slot,
|
|
237
|
+
unstyled,
|
|
238
|
+
}: {
|
|
239
|
+
issued: IssuedApiTokenView;
|
|
240
|
+
onDone: () => void;
|
|
241
|
+
messages: McpAccessMessages;
|
|
242
|
+
slot: Slot;
|
|
243
|
+
unstyled: boolean | undefined;
|
|
244
|
+
}) {
|
|
245
|
+
const snippet = (title: string, value: string) => <Snippet key={title} title={title} value={value} messages={messages} slot={slot} unstyled={unstyled} />;
|
|
246
|
+
const headingRef = useRef<HTMLHeadingElement>(null);
|
|
247
|
+
useEffect(() => {
|
|
248
|
+
headingRef.current?.focus();
|
|
249
|
+
}, [issued.id]);
|
|
250
|
+
return (
|
|
251
|
+
<section className={slot("section")}>
|
|
252
|
+
<h3 ref={headingRef} tabIndex={-1} className={slot("heading")}>{formatMessage(messages.apiIssued.title, { name: issued.name })}</h3>
|
|
253
|
+
<p className={slot("text")}>{messages.apiIssued.once}</p>
|
|
254
|
+
<p className={slot("text")}>
|
|
255
|
+
{issued.expiresText} {formatMessage(messages.apiIssued.scopes, { scopes: issued.scopes.join(", ") })}
|
|
256
|
+
</p>
|
|
257
|
+
{snippet(messages.apiIssued.tokenTitle, issued.token)}
|
|
258
|
+
{snippet(messages.apiIssued.headerTitle, `Authorization: ${issued.authorizationHeader}`)}
|
|
259
|
+
{issued.examples.map((example) => snippet(example.title, example.text))}
|
|
260
|
+
<Button variant="primary" onClick={onDone} unstyled={unstyled}>
|
|
261
|
+
{messages.issued.done}
|
|
262
|
+
</Button>
|
|
263
|
+
</section>
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function ApiIssueForm({
|
|
268
|
+
action,
|
|
269
|
+
scopes,
|
|
270
|
+
maxTokens,
|
|
271
|
+
messages,
|
|
272
|
+
locale,
|
|
273
|
+
slot,
|
|
274
|
+
unstyled,
|
|
275
|
+
}: {
|
|
276
|
+
action: IssueTokenAction;
|
|
277
|
+
scopes: readonly TokenManagerApiScope[];
|
|
278
|
+
maxTokens: number;
|
|
279
|
+
messages: McpAccessMessages;
|
|
280
|
+
locale: Locale | undefined;
|
|
281
|
+
slot: Slot;
|
|
282
|
+
unstyled: boolean | undefined;
|
|
283
|
+
}) {
|
|
284
|
+
const [state, formAction, isPending] = useActionState(action, { status: "idle" });
|
|
285
|
+
const [dismissedId, setDismissedId] = useState<string | null>(null);
|
|
286
|
+
const issued = state.status === "api-ok" && state.issued.id !== dismissedId ? state.issued : null;
|
|
287
|
+
const formRef = useRef<HTMLFormElement>(null);
|
|
288
|
+
function dismiss(id: string) {
|
|
289
|
+
setDismissedId(id);
|
|
290
|
+
requestAnimationFrame(() => {
|
|
291
|
+
const field = formRef.current?.elements.namedItem("name");
|
|
292
|
+
if (field instanceof HTMLInputElement) field.focus();
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
return (
|
|
296
|
+
<>
|
|
297
|
+
<section className={slot("section")}>
|
|
298
|
+
<h2 className={slot("heading")}>{messages.apiIssue.title}</h2>
|
|
299
|
+
<form ref={formRef} action={formAction} className={slot("list")}>
|
|
300
|
+
<input type="hidden" name="kind" value="api" />
|
|
301
|
+
<TextField
|
|
302
|
+
name="name"
|
|
303
|
+
label={messages.apiIssue.name}
|
|
304
|
+
hint={messages.apiIssue.nameHint}
|
|
305
|
+
maxLength={MAX_TOKEN_NAME_LENGTH}
|
|
306
|
+
required
|
|
307
|
+
autoComplete="off"
|
|
308
|
+
locale={locale}
|
|
309
|
+
unstyled={unstyled}
|
|
310
|
+
/>
|
|
311
|
+
<p className={slot("heading")}>{messages.apiIssue.scopes}</p>
|
|
312
|
+
{scopes
|
|
313
|
+
.filter((scope) => scope.isOffered)
|
|
314
|
+
.map((scope) => (
|
|
315
|
+
<Checkbox key={scope.name} name={`scope:${scope.name}`} label={scope.name} description={scope.description} unstyled={unstyled} />
|
|
316
|
+
))}
|
|
317
|
+
<p className={slot("text")}>{formatMessage(messages.issue.limit, { max: maxTokens })}</p>
|
|
318
|
+
<FormError message={state.status === "error" ? (state.message ?? getTokenErrorMessage(messages, state.error)) : undefined} unstyled={unstyled} />
|
|
319
|
+
<Button type="submit" variant="primary" pending={isPending} unstyled={unstyled}>
|
|
320
|
+
{isPending ? messages.apiIssue.pending : messages.apiIssue.submit}
|
|
321
|
+
</Button>
|
|
322
|
+
</form>
|
|
323
|
+
</section>
|
|
324
|
+
{issued === null ? null : <IssuedApiTokenPanel issued={issued} onDone={() => dismiss(issued.id)} messages={messages} slot={slot} unstyled={unstyled} />}
|
|
325
|
+
</>
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
214
329
|
function TokenItem({
|
|
215
330
|
row,
|
|
216
331
|
action,
|
|
@@ -237,6 +352,7 @@ function TokenItem({
|
|
|
237
352
|
</form>
|
|
238
353
|
</div>
|
|
239
354
|
<p className={row.isExpired ? slot("details") : slot("badge")}>
|
|
355
|
+
{row.kindText === undefined ? null : `${row.kindText} · `}
|
|
240
356
|
{row.statusText} · {row.scopeText}
|
|
241
357
|
</p>
|
|
242
358
|
<p className={slot("details")}>{row.details.join(" · ")}</p>
|
|
@@ -286,6 +402,7 @@ export function TokenManager({
|
|
|
286
402
|
revokeAction,
|
|
287
403
|
grants,
|
|
288
404
|
revokeGrantAction,
|
|
405
|
+
apiScopes,
|
|
289
406
|
messages,
|
|
290
407
|
locale,
|
|
291
408
|
classNames,
|
|
@@ -313,6 +430,26 @@ export function TokenManager({
|
|
|
313
430
|
)}
|
|
314
431
|
</section>
|
|
315
432
|
<IssueForm action={issueAction} allowWrites={allowWrites} maxTokens={maxTokens} messages={messages} locale={locale} slot={slot} unstyled={unstyled} />
|
|
433
|
+
{apiScopes === undefined ? null : (
|
|
434
|
+
<>
|
|
435
|
+
<section className={slot("section")}>
|
|
436
|
+
<h2 className={slot("heading")}>{messages.apiScopes.title}</h2>
|
|
437
|
+
<p className={slot("text")}>{messages.apiScopes.lead}</p>
|
|
438
|
+
<ul className={slot("list")}>
|
|
439
|
+
{apiScopes.map((scope) => (
|
|
440
|
+
<li key={scope.name} className={slot("item")}>
|
|
441
|
+
<div className={slot("itemHeader")}>
|
|
442
|
+
<code className={slot("heading")}>{scope.name}</code>
|
|
443
|
+
<span className={slot("badge")}>{scope.accessText}</span>
|
|
444
|
+
</div>
|
|
445
|
+
<p className={slot("text")}>{scope.description}</p>
|
|
446
|
+
</li>
|
|
447
|
+
))}
|
|
448
|
+
</ul>
|
|
449
|
+
</section>
|
|
450
|
+
<ApiIssueForm action={issueAction} scopes={apiScopes} maxTokens={maxTokens} messages={messages} locale={locale} slot={slot} unstyled={unstyled} />
|
|
451
|
+
</>
|
|
452
|
+
)}
|
|
316
453
|
<section className={slot("section")}>
|
|
317
454
|
<h2 className={slot("heading")}>{messages.list.title}</h2>
|
|
318
455
|
{tokens.length === 0 ? (
|