akanjs 3.1.0-rc.21 → 3.1.0-rc.22

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 (109) hide show
  1. package/client/cookie.ts +5 -0
  2. package/client/csrLocation.ts +17 -72
  3. package/client/csrTypes.ts +17 -4
  4. package/client/route/PageDefinition.ts +103 -10
  5. package/client/route/RouteDefinition.ts +44 -3
  6. package/client/route/RouteGuards.ts +64 -0
  7. package/client/route/index.ts +1 -0
  8. package/client/route/resolveRouteModule.ts +8 -0
  9. package/client/route/routeArgs.ts +9 -0
  10. package/client/router.ts +11 -0
  11. package/common/authCookie.ts +13 -0
  12. package/common/authToken.ts +1 -12
  13. package/common/index.ts +2 -8
  14. package/common/routeConvention.ts +2 -0
  15. package/constant/inputParser.ts +1 -1
  16. package/dictionary/base.dictionary.ts +8 -0
  17. package/fetch/appScope.ts +54 -0
  18. package/fetch/client/fetchClient.ts +46 -29
  19. package/fetch/index.ts +1 -0
  20. package/package.json +1 -1
  21. package/server/mcp/McpApps.ts +44 -50
  22. package/server/mcp/McpPages.ts +93 -19
  23. package/server/mcp/McpRouter.ts +14 -11
  24. package/server/ops/migrateJobs.ts +145 -0
  25. package/server/ops/opsRoute.ts +39 -7
  26. package/server/ops/opsToken.ts +7 -2
  27. package/server/routeElementComposer.tsx +33 -6
  28. package/server/routeTreeBuilder.ts +5 -0
  29. package/server/rscClient.tsx +20 -0
  30. package/server/rscPagePrompts.ts +66 -16
  31. package/server/rscWorker.tsx +33 -11
  32. package/server/rscWorkerHost.ts +12 -1
  33. package/server/webRouter.ts +31 -27
  34. package/service/predefinedAdaptor/sql/SqlDocumentStore.ts +12 -1
  35. package/service/predefinedAdaptor/sql/dialect/postgres.ts +4 -3
  36. package/service/predefinedAdaptor/sql/dialect/sqlite.ts +2 -2
  37. package/service/predefinedAdaptor/sql/types.ts +2 -0
  38. package/service/predefinedAdaptor/sql/values.ts +19 -0
  39. package/signal/mcp/mcpProtocol.ts +7 -0
  40. package/signal/mcp/pagePrompt.ts +18 -1
  41. package/types/client/csrLocation.d.ts +3 -7
  42. package/types/client/csrTypes.d.ts +20 -3
  43. package/types/client/route/PageDefinition.d.ts +21 -4
  44. package/types/client/route/RouteDefinition.d.ts +4 -0
  45. package/types/client/route/RouteGuards.d.ts +15 -0
  46. package/types/client/route/index.d.ts +1 -0
  47. package/types/client/route/resolveRouteModule.d.ts +2 -0
  48. package/types/client/route/routeArgs.d.ts +5 -0
  49. package/types/client/route/routeBuilders.d.ts +1 -1
  50. package/types/client/router.d.ts +3 -0
  51. package/types/common/authCookie.d.ts +5 -0
  52. package/types/common/authToken.d.ts +0 -5
  53. package/types/common/index.d.ts +2 -1
  54. package/types/dictionary/base.dictionary.d.ts +1 -1
  55. package/types/dictionary/dictionary.d.ts +3 -3
  56. package/types/fetch/appScope.d.ts +18 -0
  57. package/types/fetch/client/fetchClient.d.ts +1 -1
  58. package/types/fetch/index.d.ts +1 -0
  59. package/types/server/mcp/McpApps.d.ts +14 -8
  60. package/types/server/mcp/McpPages.d.ts +6 -3
  61. package/types/server/ops/migrateJobs.d.ts +36 -0
  62. package/types/server/ops/opsRoute.d.ts +4 -1
  63. package/types/server/ops/opsToken.d.ts +3 -0
  64. package/types/server/routeElementComposer.d.ts +10 -1
  65. package/types/server/rscPagePrompts.d.ts +3 -1
  66. package/types/server/rscWorkerHost.d.ts +2 -1
  67. package/types/server/webRouter.d.ts +3 -0
  68. package/types/service/predefinedAdaptor/sql/dialect/postgres.d.ts +1 -1
  69. package/types/service/predefinedAdaptor/sql/dialect/sqlite.d.ts +1 -1
  70. package/types/service/predefinedAdaptor/sql/types.d.ts +2 -0
  71. package/types/service/predefinedAdaptor/sql/values.d.ts +1 -0
  72. package/types/signal/mcp/mcpProtocol.d.ts +6 -0
  73. package/types/signal/mcp/pagePrompt.d.ts +16 -0
  74. package/types/ui/Layout/FrameSlotPortal.d.ts +1 -1
  75. package/types/ui/System/App.d.ts +17 -0
  76. package/types/ui/System/index.d.ts +2 -0
  77. package/types/ui/recipe/badgeRecipe.d.ts +2 -2
  78. package/types/ui/recipe/buttonRecipe.d.ts +2 -2
  79. package/types/vendor/@akanjs/native/packages/cli/src/lib/toolchains.d.ts +1 -1
  80. package/types/webkit/CsrGuards.d.ts +25 -0
  81. package/types/webkit/CsrStack.d.ts +6 -0
  82. package/types/webkit/McpAppBoot.d.ts +32 -0
  83. package/types/webkit/McpAppBridge.d.ts +10 -1
  84. package/types/webkit/McpAppFetchBridge.d.ts +3 -1
  85. package/types/webkit/McpAppView.d.ts +4 -1
  86. package/types/webkit/RenderLayer.d.ts +1 -3
  87. package/types/webkit/index.d.ts +1 -0
  88. package/ui/Layout/BottomInset.tsx +2 -11
  89. package/ui/Layout/FrameSlotPortal.tsx +1 -3
  90. package/ui/Layout/Navbar.tsx +7 -22
  91. package/ui/Layout/TopInset.tsx +3 -4
  92. package/ui/System/App.tsx +43 -0
  93. package/ui/System/CSR.tsx +1 -10
  94. package/ui/System/index.tsx +3 -0
  95. package/vendor/@akanjs/native/packages/cli/src/lib/toolchains.ts +1 -1
  96. package/webkit/CsrGuards.ts +112 -0
  97. package/webkit/CsrRouteTable.ts +8 -12
  98. package/webkit/CsrStack.ts +29 -1
  99. package/webkit/McpAppBoot.tsx +129 -0
  100. package/webkit/McpAppBridge.ts +30 -6
  101. package/webkit/McpAppFetchBridge.ts +6 -1
  102. package/webkit/McpAppView.ts +39 -28
  103. package/webkit/RenderLayer.tsx +98 -41
  104. package/webkit/bootCsr.tsx +4 -43
  105. package/webkit/index.ts +1 -0
  106. package/webkit/useCsrValues.ts +55 -6
  107. package/webkit/useLocation.ts +1 -2
  108. package/types/webkit/EmbeddedLayout.d.ts +0 -10
  109. package/webkit/EmbeddedLayout.tsx +0 -82
package/common/index.ts CHANGED
@@ -1,12 +1,6 @@
1
1
  export { applyMixins, getAllPropertyDescriptors } from "./applyMixins";
2
- export {
3
- authTokenKey,
4
- cookieHeaderHasAuthToken,
5
- isAuthTokenKey,
6
- isOwnAuthToken,
7
- legacyAuthTokenKey,
8
- readAuthToken,
9
- } from "./authToken";
2
+ export { cookieHeaderHasAuthToken, isAuthTokenKey, legacyAuthTokenKey } from "./authCookie";
3
+ export { authTokenKey, isOwnAuthToken, readAuthToken } from "./authToken";
10
4
  export { Backoff, type BackoffPolicy } from "./backoff";
11
5
  export { bearerTokenOf } from "./bearerTokenOf";
12
6
  export { CodeAgentClient, type CodeAgentTransport } from "./CodeAgentClient";
@@ -16,6 +16,7 @@ export const PAGE_ROUTE_EXPORTS: ReadonlySet<string> = new Set([
16
16
  "head",
17
17
  "generateHead",
18
18
  "Loading",
19
+ "guard",
19
20
  ]);
20
21
  export const LAYOUT_ROUTE_EXPORTS: ReadonlySet<string> = new Set([...PAGE_ROUTE_EXPORTS, "NotFound", "Error"]);
21
22
  export const ROOT_LAYOUT_ROUTE_EXPORTS: ReadonlySet<string> = new Set([
@@ -27,6 +28,7 @@ export const ROOT_LAYOUT_ROUTE_EXPORTS: ReadonlySet<string> = new Set([
27
28
  "wsConnect",
28
29
  "layoutStyle",
29
30
  "viewport",
31
+ "McpAppRoot",
30
32
  ]);
31
33
  /** Root-layout exports that are plain config rather than components, so a PascalCase check cannot allow them. */
32
34
  export const RESERVED_ROUTE_CONFIG_EXPORTS: ReadonlySet<string> = new Set(
@@ -45,12 +45,12 @@ export class InputParser {
45
45
  ]);
46
46
 
47
47
  static parse(argRef: Cls, arrDepth: number, value: unknown, option: ParseOption) {
48
- if (PrimitiveRegistry.has(argRef)) return deserialize(argRef, arrDepth, value, option);
49
48
  const { key, nullable = false } = option;
50
49
  if (value === null || value === undefined) {
51
50
  if (nullable) return null;
52
51
  throw new InvalidInputError(key, null);
53
52
  }
53
+ if (PrimitiveRegistry.has(argRef)) return deserialize(argRef, arrDepth, value, option);
54
54
  return InputParser.#walk(value, arrDepth, key, (item, path) => InputParser.#value(argRef, item, path));
55
55
  }
56
56
 
@@ -65,6 +65,14 @@ export const baseDictionary = serviceDictionary(["en", "ko"])
65
65
  "{endpoint} is closed: the slice's guards name none that allows it.",
66
66
  "{endpoint}이(가) 닫혀 있습니다. 슬라이스 guards에 이 동작을 허용하는 guard를 지정해야 합니다.",
67
67
  ],
68
+ mutationInApp: [
69
+ "The {app} app called {endpoint} while it loads, and an app may load with queries only.",
70
+ "{app} 앱이 불러오는 중에 {endpoint}을(를) 호출했습니다. 앱은 쿼리로만 불러올 수 있습니다.",
71
+ ],
72
+ readOnlyDeployment: [
73
+ "This server is read-only, so {endpoint} cannot run here.",
74
+ "읽기 전용 서버라 {endpoint}을(를) 실행할 수 없습니다.",
75
+ ],
68
76
  })
69
77
  .translate({
70
78
  somethingWrong: ["Something's wrong!", "문제가 생겼어요!"],
@@ -0,0 +1,54 @@
1
+ interface AppScopeStorage {
2
+ run<T>(app: string, load: () => T): T;
3
+ getStore(): string | undefined;
4
+ }
5
+
6
+ declare global {
7
+ var __AKAN_APP_SCOPE__: AppScopeStorage | undefined;
8
+ }
9
+
10
+ /**
11
+ * Marks the run of a page's `.app()` load, which may only query: an MCP view replays those queries from the tool
12
+ * result's snapshot, so a write in the load would run once for the tool call and again when the view draws.
13
+ */
14
+ export class AppScope {
15
+ static readonly #storage: AppScopeStorage = AppScope.#shared();
16
+
17
+ static run<T>(app: string, load: () => T): T {
18
+ return AppScope.#storage.run(app, load);
19
+ }
20
+
21
+ /** The app whose load is running here, if any. */
22
+ static current(): string | undefined {
23
+ return AppScope.#storage.getStore();
24
+ }
25
+
26
+ static #shared(): AppScopeStorage {
27
+ globalThis.__AKAN_APP_SCOPE__ ??= AppScope.#createStorage();
28
+ return globalThis.__AKAN_APP_SCOPE__;
29
+ }
30
+
31
+ static #createStorage(): AppScopeStorage {
32
+ if (typeof window === "undefined") {
33
+ try {
34
+ const { AsyncLocalStorage } = require("node:async_hooks") as typeof import("node:async_hooks");
35
+ const als = new AsyncLocalStorage<string>();
36
+ return { run: (app, load) => als.run(app, load), getStore: () => als.getStore() };
37
+ } catch {}
38
+ }
39
+
40
+ let current: string | undefined;
41
+ return {
42
+ run(app, load) {
43
+ const previous = current;
44
+ current = app;
45
+ try {
46
+ return load();
47
+ } finally {
48
+ current = previous;
49
+ }
50
+ },
51
+ getStore: () => current,
52
+ };
53
+ }
54
+ }
@@ -20,6 +20,7 @@ import type {
20
20
  ServiceSignal,
21
21
  } from "akanjs/signal";
22
22
  import { agentTurnConstant } from "../agentTurn";
23
+ import { AppScope } from "../appScope";
23
24
  import type { ClientSignal, FetchClientType, FetchSignalInput, MergeAllFetchTypes, SliceMeta } from "../fetchType";
24
25
  import {
25
26
  claimRequestQuery,
@@ -30,7 +31,7 @@ import {
30
31
  import type { GetSliceMetaObjFromDatabaseSignals } from "../types";
31
32
  import { FetchHandle } from "./fetchHandle";
32
33
  import { HttpClient } from "./httpClient";
33
- import type { ErrorConstructor, RestoredError } from "./remoteError";
34
+ import { type ErrorConstructor, type RestoredError, restoreRemoteError } from "./remoteError";
34
35
  import { SliceInitHandle } from "./sliceInitHandle";
35
36
  import { expandQueryArgs, normalizeQueryArgs } from "./sliceQueryArgs";
36
37
  import { WsClient, type WsClientOptions } from "./wsClient";
@@ -440,8 +441,8 @@ export class FetchClient {
440
441
  const payload = claim.owned ? response : FetchClient.#deepCopy(response);
441
442
  return parseReturn(payload, { crystalize: option?.crystalize ?? true });
442
443
  };
443
- case "mutation":
444
- return async (...argData: unknown[]) => {
444
+ case "mutation": {
445
+ const send = async (...argData: unknown[]) => {
445
446
  const { option, argMap, url } = requestOf(argData);
446
447
  const bridge = this.#bridgeOf(option?.origin);
447
448
  if (bridge)
@@ -458,6 +459,12 @@ export class FetchClient {
458
459
  });
459
460
  return parseReturn(response, { crystalize: option?.crystalize ?? true });
460
461
  };
462
+
463
+ return (...argData: unknown[]) => {
464
+ this.#refuseInApp(key);
465
+ return send(...argData);
466
+ };
467
+ }
461
468
  default:
462
469
  throw new Error(`Unsupported endpoint type: ${endpoint.type}`);
463
470
  }
@@ -476,6 +483,7 @@ export class FetchClient {
476
483
  const parseReturn = this.#makeReturnParser(endpoint.returns);
477
484
  const wrappedListeners = new WeakMap<(data: unknown) => void, (data: unknown) => void>();
478
485
  return (...argData: unknown[]) => {
486
+ this.#refuseInApp(key);
479
487
 
480
488
  if (FetchClient.bridged) return () => undefined;
481
489
  const args = argData.slice(0, roomArgLength);
@@ -507,6 +515,7 @@ export class FetchClient {
507
515
  const msgArgLength = msgArgs.length;
508
516
  const serializerMap = this.#makeArgSerializer(endpoint.args);
509
517
  return (...argData: unknown[]) => {
518
+ this.#refuseInApp(key);
510
519
  if (FetchClient.bridged) return;
511
520
  const args = argData.slice(0, msgArgLength);
512
521
  const fetchPolicy = argData[msgArgLength] as FetchPolicy | undefined;
@@ -536,6 +545,12 @@ export class FetchClient {
536
545
  }
537
546
  }
538
547
 
548
+ #refuseInApp(endpoint: string) {
549
+ const app = AppScope.current();
550
+ if (app)
551
+ throw restoreRemoteError({ error: "base.error.mutationInApp", data: { app, endpoint } }, 400, this.ErrorCls);
552
+ }
553
+
539
554
  #bridgeOf(origin?: string) {
540
555
  return origin || this.#foreign ? null : FetchClient.bridge;
541
556
  }
@@ -707,32 +722,34 @@ export class FetchClient {
707
722
  );
708
723
  }
709
724
 
710
- this.#setHandlerFactory(
711
- names.addModelFiles,
712
- () =>
713
- (async (fileList: FileList | File[], parentId?: string, option?: FetchPolicy) => {
714
-
715
- if (this.#bridgeOf())
716
- throw new Error("File upload is unavailable in an MCP App view: its host relays tool calls, not files.");
717
- const cap = resolveFileUploadCapability(this.serializedSignal);
718
- const endpoint = cap ? this.serializedSignal[cap.refName]?.endpoint[cap.endpointKey] : undefined;
719
- if (!cap || !endpoint)
720
- throw new Error(
721
- "File upload is not configured. Mark an upload mutation with { fileUpload: true } (e.g. shared FileEndpoint.addFiles).",
722
- );
723
- const { fields, buildMetas } = fileUploadContract;
724
- const formData = new FormData();
725
- for (let i = 0; i < fileList.length; i++) formData.append(fields.files, fileList[i]);
726
- formData.append(fields.metas, JSON.stringify(buildMetas(fileList)));
727
- formData.append(fields.type, refName);
728
- if (parentId) formData.append(fields.parentId, parentId);
729
- const url = FetchClient.makeHttpUrl(cap.endpointKey, endpoint, cap.prefix, new Map());
730
- return await this.http.post(url, formData, {
731
- headers: this.#makeRequestHeaders(option),
732
- baseUrl: this.#baseUrlOf(endpoint),
733
- });
734
- }) as FetchHandler,
735
- );
725
+ this.#setHandlerFactory(names.addModelFiles, () => {
726
+ const upload = async (fileList: FileList | File[], parentId?: string, option?: FetchPolicy) => {
727
+
728
+ if (this.#bridgeOf())
729
+ throw new Error("File upload is unavailable in an MCP App view: its host relays tool calls, not files.");
730
+ const cap = resolveFileUploadCapability(this.serializedSignal);
731
+ const endpoint = cap ? this.serializedSignal[cap.refName]?.endpoint[cap.endpointKey] : undefined;
732
+ if (!cap || !endpoint)
733
+ throw new Error(
734
+ "File upload is not configured. Mark an upload mutation with { fileUpload: true } (e.g. shared FileEndpoint.addFiles).",
735
+ );
736
+ const { fields, buildMetas } = fileUploadContract;
737
+ const formData = new FormData();
738
+ for (let i = 0; i < fileList.length; i++) formData.append(fields.files, fileList[i]);
739
+ formData.append(fields.metas, JSON.stringify(buildMetas(fileList)));
740
+ formData.append(fields.type, refName);
741
+ if (parentId) formData.append(fields.parentId, parentId);
742
+ const url = FetchClient.makeHttpUrl(cap.endpointKey, endpoint, cap.prefix, new Map());
743
+ return await this.http.post(url, formData, {
744
+ headers: this.#makeRequestHeaders(option),
745
+ baseUrl: this.#baseUrlOf(endpoint),
746
+ });
747
+ };
748
+ return ((fileList: FileList | File[], parentId?: string, option?: FetchPolicy) => {
749
+ this.#refuseInApp(names.addModelFiles);
750
+ return upload(fileList, parentId, option);
751
+ }) as FetchHandler;
752
+ });
736
753
  }
737
754
 
738
755
  #makeModelObjFn(refName: string, callerKey: string) {
package/fetch/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./agentTurn";
2
+ export * from "./appScope";
2
3
  export * from "./client";
3
4
  export * from "./fetchType";
4
5
  export * from "./requestStorage";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.1.0-rc.21",
3
+ "version": "3.1.0-rc.22",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -14,23 +14,29 @@ import {
14
14
  import type { PagePromptEntry, PagePromptRecord, PagePromptRun } from "../../signal/mcp/pagePrompt";
15
15
  import { McpAppShell } from "./McpAppShell";
16
16
 
17
- /** The CSR shell a view boots from: one single-file HTML per basePath, read from the build that serves the web. */
17
+ /** The single-file view each `.app()` boots from, read from the build that serves the web. */
18
18
  export interface McpAppShellSource {
19
- readonly basePaths: readonly string[];
20
- /** `""` is the app without a basePath; `null` when this build has no single-file shell for it. */
21
- read(basePath: string): Promise<string | null>;
19
+ /** `null` when this build has no view for the app. */
20
+ read(app: string): Promise<string | null>;
21
+ }
22
+
23
+ interface McpAppsOption {
24
+ /** Every write the view sends is refused before it leaves, as the deployment lists no write to relay it to. */
25
+ readOnly?: boolean;
22
26
  }
23
27
 
24
28
  export class McpApps {
25
29
  static readonly scheme = "ui://";
26
-
27
- static readonly rootSegment = "app";
28
30
  static readonly #tokenClaims = new Set(["iss", "aud", "exp", "nbf", "iat", "jti", "client_id", "scope"]);
31
+
32
+ static readonly #appUri = /^ui:\/\/([^/]+)\/apps\/([A-Za-z0-9_-]{1,64})$/;
29
33
 
30
34
  readonly #shell: McpAppShellSource | null;
35
+ readonly #readOnly: boolean;
31
36
 
32
- constructor(shell: McpAppShellSource | null) {
37
+ constructor(shell: McpAppShellSource | null, { readOnly = false }: McpAppsOption = {}) {
33
38
  this.#shell = shell;
39
+ this.#readOnly = readOnly;
34
40
  }
35
41
 
36
42
  get rendersUi() {
@@ -53,18 +59,19 @@ export class McpApps {
53
59
  ]),
54
60
  );
55
61
  const required = entry.arguments.filter((arg) => arg.required).map((arg) => arg.name);
62
+ const inputSchema = {
63
+ type: "object" as const,
64
+ properties,
65
+ ...(required.length ? { required } : {}),
66
+ additionalProperties: false,
67
+ };
68
+ const tool = { name: entry.name, description: entry.description, inputSchema };
69
+
70
+ if (entry.kind === "prompt") return { ...tool, annotations: { readOnlyHint: false, openWorldHint: false } };
56
71
  return {
57
- name: entry.name,
58
- description: entry.description,
59
- inputSchema: {
60
- type: "object",
61
- properties,
62
- ...(required.length ? { required } : {}),
63
- additionalProperties: false,
64
- },
65
-
66
- annotations: { readOnlyHint: false, openWorldHint: false },
67
- ...(this.#shell ? { _meta: this.#toolMetaOf(this.uriOf(this.#basePathOf(entry.pattern))) } : {}),
72
+ ...tool,
73
+ annotations: { readOnlyHint: true, openWorldHint: false },
74
+ ...(this.#shell ? { _meta: this.#toolMetaOf(this.uriOf(entry.name)) } : {}),
68
75
  };
69
76
  }
70
77
 
@@ -74,30 +81,30 @@ export class McpApps {
74
81
 
75
82
  resourcesOf(entries: PagePromptEntry[]): McpResource[] {
76
83
  if (!this.#shell) return [];
77
- const { appName } = getEnv();
78
- const basePaths = [...new Set(entries.map((entry) => this.#basePathOf(entry.pattern)))];
79
- return basePaths.map((basePath) => ({
80
- uri: this.uriOf(basePath),
81
- name: basePath || appName,
82
- description: `The ${basePath || appName} screens, rendered in place of a tool result.`,
83
- mimeType: MCP_UI_MIME_TYPE,
84
- }));
84
+ return entries
85
+ .filter((entry) => entry.kind === "app")
86
+ .map((entry) => ({
87
+ uri: this.uriOf(entry.name),
88
+ name: entry.name,
89
+ description: entry.description,
90
+ mimeType: MCP_UI_MIME_TYPE,
91
+ }));
85
92
  }
86
93
 
87
- uriOf(basePath: string) {
88
- const root = `${McpApps.scheme}${getEnv().appName.toLowerCase()}/${McpApps.rootSegment}`;
89
- return basePath ? `${root}/${basePath}` : root;
94
+ uriOf(app: string) {
95
+ return `${McpApps.scheme}${getEnv().appName.toLowerCase()}/apps/${app}`;
90
96
  }
91
97
 
92
98
  async read(uri: string, origin: string): Promise<McpResourceContents | null> {
93
- const shell = this.#shell;
94
- const basePath = shell ? this.#basePathOfUri(uri) : null;
95
- if (!shell || basePath === null) return null;
96
- const html = await shell.read(basePath);
99
+ const match = McpApps.#appUri.exec(uri);
100
+ const app = match?.[1]?.toLowerCase() === getEnv().appName.toLowerCase() ? match[2] : undefined;
101
+ if (!this.#shell || !app) return null;
102
+ const html = await this.#shell.read(app);
97
103
  return html ? McpAppShell.contentsOf(uri, html, origin) : null;
98
104
  }
99
105
 
100
106
  resultOf(
107
+ entry: PagePromptEntry,
101
108
  run: Extract<PagePromptRun, { ok: true }>,
102
109
  messages: PromptMessage[],
103
110
  webOrigin: string,
@@ -107,14 +114,15 @@ export class McpApps {
107
114
  const href = `${url.pathname}${url.search}`;
108
115
  const content: PromptContent[] = [
109
116
  ...messages.map((message) => message.content),
110
- { type: "text", text: `Open this screen in a browser: ${webOrigin}${href}` },
117
+ { type: "text", text: `Open the full screen: ${webOrigin}${href}` },
111
118
  ];
112
- if (!this.#shell || !forView) return { content, isError: false };
119
+ if (entry.kind === "prompt" || !this.#shell || !forView) return { content, isError: false };
113
120
  const _meta = {
114
121
  [MCP_APP_META.snapshot]: McpApps.#snapshotOf(run.records),
115
122
  [MCP_APP_META.account]: forView.account,
123
+ ...(this.#readOnly ? { [MCP_APP_META.readOnly]: true } : {}),
116
124
  };
117
- return { content, structuredContent: { href }, _meta, isError: false };
125
+ return { content, structuredContent: { href, ...run.route }, _meta, isError: false };
118
126
  }
119
127
 
120
128
  static accountOf(req: Request): Record<string, unknown> | null {
@@ -128,20 +136,6 @@ export class McpApps {
128
136
  }
129
137
  }
130
138
 
131
- #basePathOf(pattern: string) {
132
- const [, , segment] = pattern.split("/");
133
- return segment && this.#shell?.basePaths.includes(segment) ? segment : "";
134
- }
135
-
136
- #basePathOfUri(uri: string) {
137
- const match = /^ui:\/\/([^/]+)\/([^?#]*)$/i.exec(uri);
138
- if (!match || match[1]?.toLowerCase() !== getEnv().appName.toLowerCase()) return null;
139
- const [root, basePath, ...rest] = (match[2] ?? "").split("/");
140
- if (root !== McpApps.rootSegment || rest.length) return null;
141
- if (!basePath) return "";
142
- return this.#shell?.basePaths.includes(basePath) ? basePath : null;
143
- }
144
-
145
139
  static #snapshotOf(records: PagePromptRecord[]): McpAppSnapshotRecord[] {
146
140
  return records.flatMap(({ key, args, value }) => (value === undefined ? [] : [{ key, args, value }]));
147
141
  }
@@ -1,5 +1,11 @@
1
1
  import type { Logger } from "akanjs/common";
2
- import { MCP_APP_META, MCP_META_CLIENT_CAPABILITIES, type McpDocument, type McpEra } from "../../signal/mcp";
2
+ import {
3
+ MCP_APP_META,
4
+ MCP_META_CLIENT_CAPABILITIES,
5
+ MCP_SCREEN_REFUSAL,
6
+ type McpDocument,
7
+ type McpEra,
8
+ } from "../../signal/mcp";
3
9
  import type { PromptMessage } from "../../signal/mcp/Msg";
4
10
  import type { McpPrompt } from "../../signal/mcp/mcpProtocol";
5
11
  import type { PagePromptEntry, PagePromptRun, PagePromptSource } from "../../signal/mcp/pagePrompt";
@@ -18,6 +24,7 @@ export interface McpPagesProps {
18
24
  logger: Logger;
19
25
  promptBudget?: number;
20
26
  language?: string;
27
+ readOnly?: boolean;
21
28
  }
22
29
 
23
30
  interface McpPageCall {
@@ -33,10 +40,14 @@ export type McpPageRun =
33
40
 
34
41
  export class McpPages {
35
42
  static readonly listWaitMs = 1_000;
43
+ static readonly verdictTtlMs = 5_000;
44
+ static readonly #keptVerdicts = 256;
36
45
 
37
46
  readonly #props: McpPagesProps;
38
47
  #listed: PagePromptEntry[] | null = null;
39
48
  #listFailed = false;
49
+ #guardsFailed = false;
50
+ readonly #verdicts = new Map<string, { at: number; names: Promise<string[] | null> }>();
40
51
 
41
52
  constructor(props: McpPagesProps) {
42
53
  this.#props = props;
@@ -56,26 +67,36 @@ export class McpPages {
56
67
  logger.debug(`MCP page prompts: ${entries.length}${entries.length ? ` (${names})` : ""}`);
57
68
  const apps = this.#props.apps;
58
69
  if (!apps || !entries.length) return;
59
- for (const { name } of entries.filter((entry) => this.#isEndpointName(entry.name)))
70
+ for (const { kind, name } of entries.filter((entry) => this.#isEndpointName(entry.name)))
60
71
  logger.warn(
61
- `MCP did not publish page "${name}" as a tool: an endpoint already takes that name, so it stays a prompt only. Rename the prompt.`,
72
+ kind === "app"
73
+ ? `MCP did not publish app "${name}": an endpoint already takes that name. Rename the app.`
74
+ : `MCP did not publish page "${name}" as a tool: an endpoint already takes that name, so it stays a prompt only. Rename the prompt.`,
62
75
  );
63
- const ui = apps.rendersUi ? "rendered from the CSR shell by an MCP Apps host" : "with no view to render";
64
- logger.debug(`MCP page tools: ${ui}`);
76
+ const appCount = entries.filter((entry) => entry.kind === "app").length;
77
+ const ui = apps.rendersUi ? "each drawn by its own view in an MCP Apps host" : "with no view to draw them";
78
+ if (appCount) logger.debug(`MCP apps: ${appCount}, ${ui}`);
65
79
  })
66
80
  .catch((error: unknown) => {
67
81
  logger.warn(`MCP page prompts could not be listed: ${error instanceof Error ? error.message : error}`);
68
82
  });
69
83
  }
70
84
 
71
- async prompts(): Promise<McpPrompt[]> {
72
- return (await this.#list()).map(McpPages.#promptOf);
85
+ async prompts(req: Request): Promise<McpPrompt[]> {
86
+ const listed = await this.#list();
87
+ const prompts = listed.filter((entry) => entry.kind === "prompt");
88
+ return (await this.#visibleTo(req, prompts, listed)).map(McpPages.#promptOf);
73
89
  }
74
90
 
75
91
  async entry(name: string) {
76
92
  return (await this.#list()).find((candidate) => candidate.name === name);
77
93
  }
78
94
 
95
+ async prompt(name: string) {
96
+ const entry = await this.entry(name);
97
+ return entry?.kind === "prompt" ? entry : undefined;
98
+ }
99
+
79
100
  async toolCall(
80
101
  call: McpPageCall,
81
102
  document: McpDocument,
@@ -89,7 +110,7 @@ export class McpPages {
89
110
  if (!page.run || !apps) return { result: { content: page.messages.map((message) => message.content) } };
90
111
  const origin = this.#props.publicOrigin(call.req);
91
112
  const forView = call.ui ? { account: McpApps.accountOf(call.req) } : null;
92
- return { result: apps.resultOf(page.run, page.messages, origin, forView) };
113
+ return { result: apps.resultOf(entry, page.run, page.messages, origin, forView) };
93
114
  }
94
115
 
95
116
  async run(
@@ -133,10 +154,10 @@ export class McpPages {
133
154
  run.reason === "argument" || run.reason === "unknown"
134
155
  ? run.message
135
156
  : gated
136
- ? "This screen is not available to the signed-in account."
157
+ ? MCP_SCREEN_REFUSAL.gated
137
158
  : run.reason === "not-found"
138
- ? "No screen exists for these arguments."
139
- : "The page failed to load.";
159
+ ? MCP_SCREEN_REFUSAL.notFound
160
+ : MCP_SCREEN_REFUSAL.failed;
140
161
  return { refusal: message };
141
162
  }
142
163
  return { run, messages: await composer.compose(entry, run) };
@@ -144,20 +165,23 @@ export class McpPages {
144
165
 
145
166
  async toolOf(name: unknown) {
146
167
  if (!this.#props.apps || typeof name !== "string" || this.#isEndpointName(name)) return undefined;
147
- return await this.entry(name).catch(() => undefined);
168
+ const entry = await this.entry(name).catch(() => undefined);
169
+ return entry && this.#publishesTool(entry) ? entry : undefined;
148
170
  }
149
171
 
150
- async tools(ui: boolean, document: McpDocument) {
172
+ async tools(ui: boolean, document: McpDocument, req: Request) {
151
173
  const apps = this.#props.apps;
152
174
  if (!apps) return [];
153
- const pages = (await this.#pageTools()).map((entry) => apps.toolOf(entry));
175
+ const listed = await this.#listedPages();
176
+ const pages = (await this.#visibleTo(req, this.#pageToolsOf(listed), listed)).map((entry) => apps.toolOf(entry));
154
177
  return ui ? [...document.appTools, ...pages] : pages;
155
178
  }
156
179
 
157
- async resources(ui: boolean) {
180
+ async resources(ui: boolean, req: Request) {
158
181
  const apps = this.#props.apps;
159
182
  if (!apps?.rendersUi || !ui) return [];
160
- return apps.resourcesOf(await this.#listedPages());
183
+ const listed = await this.#listedPages();
184
+ return apps.resourcesOf(await this.#visibleTo(req, this.#pageToolsOf(listed), listed));
161
185
  }
162
186
 
163
187
  async uiCapable(era: McpEra, meta: Record<string, unknown> | null, req: Request) {
@@ -206,12 +230,58 @@ export class McpPages {
206
230
  }
207
231
 
208
232
  async #listedPages(): Promise<PagePromptEntry[]> {
233
+ return (await McpPages.#withinListWait(this.#pagesOrNone())) ?? this.#listed ?? [];
234
+ }
235
+
236
+ async #visibleTo(req: Request, entries: PagePromptEntry[], listed: PagePromptEntry[]): Promise<PagePromptEntry[]> {
237
+ if (!this.#props.source || !entries.length) return entries;
238
+
239
+ const names = await McpPages.#withinListWait(this.#verdictOf(req, listed));
240
+ if (!names) return entries;
241
+ const admitted = new Set(names);
242
+ return entries.filter((entry) => admitted.has(entry.name));
243
+ }
244
+
245
+ #verdictOf(req: Request, entries: PagePromptEntry[]): Promise<string[] | null> {
246
+ const headers = McpPages.#forwardedHeaders(req);
247
+ const names = entries.map((entry) => entry.name);
248
+ const key = JSON.stringify([headers, names]);
249
+ const now = Date.now();
250
+ const kept = this.#verdicts.get(key);
251
+ if (kept && now - kept.at < McpPages.verdictTtlMs) return kept.names;
252
+ const source = this.#props.source;
253
+ const verdict = (
254
+ source ? source.visible({ names, headers, language: this.#props.language }) : Promise.resolve(names)
255
+ )
256
+ .then((visible) => {
257
+ this.#guardsFailed = false;
258
+ return visible;
259
+ })
260
+ .catch((error: unknown) => {
261
+ if (this.#verdicts.get(key)?.names === verdict) this.#verdicts.delete(key);
262
+ if (!this.#guardsFailed)
263
+ this.#props.logger.warn(
264
+ `MCP could not run the route guards of its page prompts, so they are listed unfiltered: ${error instanceof Error ? error.message : error}`,
265
+ );
266
+ this.#guardsFailed = true;
267
+ return null;
268
+ });
269
+ this.#verdicts.delete(key);
270
+ this.#verdicts.set(key, { at: now, names: verdict });
271
+ for (const [oldest, { at }] of this.#verdicts) {
272
+ if (this.#verdicts.size <= McpPages.#keptVerdicts && now - at < McpPages.verdictTtlMs) break;
273
+ this.#verdicts.delete(oldest);
274
+ }
275
+ return verdict;
276
+ }
277
+
278
+ static async #withinListWait<T>(work: Promise<T>): Promise<T | null> {
209
279
  let timer: ReturnType<typeof setTimeout> | undefined;
210
280
  const stalled = new Promise<null>((resolve) => {
211
281
  timer = setTimeout(() => resolve(null), McpPages.listWaitMs);
212
282
  });
213
283
  try {
214
- return (await Promise.race([this.#pagesOrNone(), stalled])) ?? this.#listed ?? [];
284
+ return await Promise.race([work, stalled]);
215
285
  } finally {
216
286
  clearTimeout(timer);
217
287
  }
@@ -236,8 +306,12 @@ export class McpPages {
236
306
  return !!this.#props.document().findTool(name) || this.#props.dispatcher.isEndpoint(name);
237
307
  }
238
308
 
239
- async #pageTools() {
309
+ #pageToolsOf(listed: PagePromptEntry[]) {
240
310
  if (!this.#props.apps) return [];
241
- return (await this.#listedPages()).filter((entry) => !this.#isEndpointName(entry.name));
311
+ return listed.filter((entry) => !this.#isEndpointName(entry.name) && this.#publishesTool(entry));
312
+ }
313
+
314
+ #publishesTool(entry: PagePromptEntry) {
315
+ return entry.kind === "app" || !this.#props.readOnly;
242
316
  }
243
317
  }