@softure-ai/mcp-access 0.2.8 → 0.3.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.
Files changed (153) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +270 -19
  3. package/dist/cli/run.d.ts +1 -1
  4. package/dist/cli/run.d.ts.map +1 -1
  5. package/dist/cli/run.js +2 -2
  6. package/dist/cli/run.js.map +1 -1
  7. package/dist/contract.d.ts +48 -2
  8. package/dist/contract.d.ts.map +1 -1
  9. package/dist/index.d.ts +96 -3
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +26 -5
  12. package/dist/index.js.map +1 -1
  13. package/dist/jobs/index.d.ts +2 -0
  14. package/dist/jobs/index.d.ts.map +1 -1
  15. package/dist/jobs/index.js +5 -2
  16. package/dist/jobs/index.js.map +1 -1
  17. package/dist/jobs/operation-state.d.ts +58 -0
  18. package/dist/jobs/operation-state.d.ts.map +1 -0
  19. package/dist/jobs/operation-state.js +68 -0
  20. package/dist/jobs/operation-state.js.map +1 -0
  21. package/dist/jobs/operation-tasks.d.ts +61 -0
  22. package/dist/jobs/operation-tasks.d.ts.map +1 -0
  23. package/dist/jobs/operation-tasks.js +161 -0
  24. package/dist/jobs/operation-tasks.js.map +1 -0
  25. package/dist/jobs/operations.d.ts +68 -0
  26. package/dist/jobs/operations.d.ts.map +1 -0
  27. package/dist/jobs/operations.js +110 -0
  28. package/dist/jobs/operations.js.map +1 -0
  29. package/dist/messages/en.d.ts +57 -1
  30. package/dist/messages/en.d.ts.map +1 -1
  31. package/dist/messages/en.js +57 -1
  32. package/dist/messages/en.js.map +1 -1
  33. package/dist/messages/index.d.ts +56 -1
  34. package/dist/messages/index.d.ts.map +1 -1
  35. package/dist/next/issue-token.d.ts +11 -2
  36. package/dist/next/issue-token.d.ts.map +1 -1
  37. package/dist/next/issue-token.js +56 -7
  38. package/dist/next/issue-token.js.map +1 -1
  39. package/dist/next/oauth-page.d.ts +6 -1
  40. package/dist/next/oauth-page.d.ts.map +1 -1
  41. package/dist/next/oauth-page.js +6 -5
  42. package/dist/next/oauth-page.js.map +1 -1
  43. package/dist/next/token-manager-props.d.ts +3 -2
  44. package/dist/next/token-manager-props.d.ts.map +1 -1
  45. package/dist/next/token-manager-props.js +12 -2
  46. package/dist/next/token-manager-props.js.map +1 -1
  47. package/dist/options.d.ts +47 -0
  48. package/dist/options.d.ts.map +1 -1
  49. package/dist/options.js +53 -0
  50. package/dist/options.js.map +1 -1
  51. package/dist/schema.d.ts +214 -0
  52. package/dist/schema.d.ts.map +1 -1
  53. package/dist/schema.js +21 -3
  54. package/dist/schema.js.map +1 -1
  55. package/dist/server/api-tokens.d.ts +64 -0
  56. package/dist/server/api-tokens.d.ts.map +1 -0
  57. package/dist/server/api-tokens.js +176 -0
  58. package/dist/server/api-tokens.js.map +1 -0
  59. package/dist/server/confirmation.d.ts +69 -0
  60. package/dist/server/confirmation.d.ts.map +1 -0
  61. package/dist/server/confirmation.js +220 -0
  62. package/dist/server/confirmation.js.map +1 -0
  63. package/dist/server/endpoint.d.ts.map +1 -1
  64. package/dist/server/endpoint.js +6 -4
  65. package/dist/server/endpoint.js.map +1 -1
  66. package/dist/server/health.d.ts.map +1 -1
  67. package/dist/server/health.js +3 -1
  68. package/dist/server/health.js.map +1 -1
  69. package/dist/server/index.d.ts +5 -2
  70. package/dist/server/index.d.ts.map +1 -1
  71. package/dist/server/index.js +4 -1
  72. package/dist/server/index.js.map +1 -1
  73. package/dist/server/oauth-http.d.ts +5 -0
  74. package/dist/server/oauth-http.d.ts.map +1 -1
  75. package/dist/server/oauth-http.js +11 -7
  76. package/dist/server/oauth-http.js.map +1 -1
  77. package/dist/server/privacy.d.ts +4 -0
  78. package/dist/server/privacy.d.ts.map +1 -1
  79. package/dist/server/privacy.js +5 -1
  80. package/dist/server/privacy.js.map +1 -1
  81. package/dist/server/prune.d.ts +5 -1
  82. package/dist/server/prune.d.ts.map +1 -1
  83. package/dist/server/prune.js +14 -4
  84. package/dist/server/prune.js.map +1 -1
  85. package/dist/server/strict-input.d.ts +3 -1
  86. package/dist/server/strict-input.d.ts.map +1 -1
  87. package/dist/server/strict-input.js +54 -8
  88. package/dist/server/strict-input.js.map +1 -1
  89. package/dist/server/tokens.d.ts +46 -11
  90. package/dist/server/tokens.d.ts.map +1 -1
  91. package/dist/server/tokens.js +46 -24
  92. package/dist/server/tokens.js.map +1 -1
  93. package/dist/server/tools.d.ts +8 -0
  94. package/dist/server/tools.d.ts.map +1 -1
  95. package/dist/server/tools.js +1 -0
  96. package/dist/server/tools.js.map +1 -1
  97. package/dist/server/writes-switch.d.ts +8 -0
  98. package/dist/server/writes-switch.d.ts.map +1 -0
  99. package/dist/server/writes-switch.js +25 -0
  100. package/dist/server/writes-switch.js.map +1 -0
  101. package/dist/stdio/serve.d.ts.map +1 -1
  102. package/dist/stdio/serve.js +5 -7
  103. package/dist/stdio/serve.js.map +1 -1
  104. package/dist/tokens/index.d.ts +6 -0
  105. package/dist/tokens/index.d.ts.map +1 -0
  106. package/dist/tokens/index.js +8 -0
  107. package/dist/tokens/index.js.map +1 -0
  108. package/dist/ui/consent-form.d.ts +2 -2
  109. package/dist/ui/consent-form.d.ts.map +1 -1
  110. package/dist/ui/consent-form.js +3 -2
  111. package/dist/ui/consent-form.js.map +1 -1
  112. package/dist/ui/index.d.ts +1 -1
  113. package/dist/ui/index.d.ts.map +1 -1
  114. package/dist/ui/index.js.map +1 -1
  115. package/dist/ui/token-manager.d.ts +18 -2
  116. package/dist/ui/token-manager.d.ts.map +1 -1
  117. package/dist/ui/token-manager.js +28 -3
  118. package/dist/ui/token-manager.js.map +1 -1
  119. package/migrations/0004_create_used_confirmations.sql +13 -0
  120. package/migrations/0005_add_api_tokens.sql +15 -0
  121. package/migrations/0006_create_operation_tasks.sql +18 -0
  122. package/module.json +10 -4
  123. package/package.json +7 -2
  124. package/src/cli/run.ts +2 -2
  125. package/src/contract.ts +60 -2
  126. package/src/index.ts +39 -4
  127. package/src/jobs/index.ts +41 -2
  128. package/src/jobs/operation-state.ts +119 -0
  129. package/src/jobs/operation-tasks.ts +229 -0
  130. package/src/jobs/operations.ts +213 -0
  131. package/src/messages/en.ts +58 -1
  132. package/src/next/issue-token.ts +64 -9
  133. package/src/next/oauth-page.tsx +12 -6
  134. package/src/next/token-manager-props.ts +15 -4
  135. package/src/options.ts +62 -0
  136. package/src/schema.ts +23 -3
  137. package/src/server/api-tokens.ts +224 -0
  138. package/src/server/confirmation.ts +316 -0
  139. package/src/server/endpoint.ts +6 -4
  140. package/src/server/health.ts +3 -1
  141. package/src/server/index.ts +33 -2
  142. package/src/server/oauth-http.ts +16 -7
  143. package/src/server/privacy.ts +9 -1
  144. package/src/server/prune.ts +17 -4
  145. package/src/server/strict-input.ts +62 -13
  146. package/src/server/tokens.ts +73 -27
  147. package/src/server/tools.ts +9 -0
  148. package/src/server/writes-switch.ts +24 -0
  149. package/src/stdio/serve.ts +5 -7
  150. package/src/tokens/index.ts +22 -0
  151. package/src/ui/consent-form.tsx +12 -8
  152. package/src/ui/index.ts +1 -0
  153. package/src/ui/token-manager.tsx +140 -3
@@ -2,13 +2,13 @@ import { formatMessage } from "@softure-ai/core";
2
2
  import { Button, Checkbox, type ClassNames, createSlotClassGetter } from "@softure-ai/ui";
3
3
  import type { McpAccessMessages } from "../messages/index.js";
4
4
 
5
- // The OAuth consent screen's body: which app (its registered name and where the code goes, which
6
- // the app cannot fake), which account (two accounts in one browser happen) and what access (read
5
+ // The OAuth consent screen's body: which app (its self-declared name), where the code goes (its own
6
+ // row: the one part the app cannot fake), which account (two accounts in one browser happen) and what access (read
7
7
  // always, change only when the client asked, the app allows it and the person ticks it). A plain
8
8
  // HTML form posting to the decision route, so the `303` to the client works without JavaScript and
9
9
  // with native app schemes. Server-safe: no hooks.
10
10
 
11
- export type ConsentFormSlot = "root" | "details" | "term" | "value" | "note" | "actions" | "text" | "link";
11
+ export type ConsentFormSlot = "root" | "details" | "term" | "value" | "target" | "note" | "actions" | "text" | "link";
12
12
 
13
13
  export interface ConsentFormProps {
14
14
  /** The decision route the form posts to. */
@@ -19,7 +19,7 @@ export interface ConsentFormProps {
19
19
  /** Where the code goes: the host for http(s), the whole URI for a native scheme. */
20
20
  readonly redirectTarget: string;
21
21
  readonly accountEmail: string;
22
- /** Offer the change checkbox: the client asked for `mcp:write` and the app allows writes. */
22
+ /** Offer the change checkbox: the client asked for `mcp:write` and the app allows writes right now. */
23
23
  readonly isWriteOffered: boolean;
24
24
  readonly messages: McpAccessMessages;
25
25
  readonly classNames?: ClassNames<ConsentFormSlot>;
@@ -31,7 +31,8 @@ const DEFAULT_CLASSES: Readonly<Record<ConsentFormSlot, string>> = {
31
31
  details: "sft:m-0 sft:flex sft:flex-col sft:gap-3 sft:text-sm",
32
32
  term: "sft:text-muted",
33
33
  value: "sft:m-0 sft:font-medium sft:text-foreground",
34
- note: "sft:font-normal sft:text-muted",
34
+ target: "sft:m-0 sft:font-mono sft:font-medium sft:text-foreground sft:break-words",
35
+ note: "sft:m-0 sft:mt-1 sft:text-xs sft:font-normal sft:text-muted",
35
36
  actions: "sft:flex sft:flex-col sft:gap-3",
36
37
  text: "sft:m-0 sft:text-sm sft:text-muted",
37
38
  link: "sft:font-medium sft:text-accent",
@@ -48,9 +49,12 @@ export function ConsentForm({ action, params, clientName, redirectTarget, accoun
48
49
  <dl className={slot("details")}>
49
50
  <div>
50
51
  <dt className={slot("term")}>{copy.app}</dt>
51
- <dd className={slot("value")}>
52
- {clientName} <span className={slot("note")}>({formatMessage(copy.returnsTo, { target: redirectTarget })})</span>
53
- </dd>
52
+ <dd className={slot("value")}>{clientName}</dd>
53
+ </div>
54
+ <div>
55
+ <dt className={slot("term")}>{copy.redirectLabel}</dt>
56
+ <dd className={slot("target")}>{redirectTarget}</dd>
57
+ <dd className={slot("note")}>{copy.redirectHint}</dd>
54
58
  </div>
55
59
  <div>
56
60
  <dt className={slot("term")}>{copy.account}</dt>
package/src/ui/index.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  export { ConsentError, ConsentForm, type ConsentErrorProps, type ConsentFormProps, type ConsentFormSlot } from "./consent-form.js";
4
4
  export {
5
5
  TokenManager,
6
+ type TokenManagerApiScope,
6
7
  type RevokeGrantAction,
7
8
  type TokenManagerGrant,
8
9
  type IssueTokenAction,
@@ -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 owner's tokens with a revoke button each, and, with OAuth on, the apps
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
- /** Read only, or read and change, already in the app's copy. */
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 ? (