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.
- package/client/cookie.ts +5 -0
- package/client/csrLocation.ts +17 -72
- package/client/csrTypes.ts +17 -4
- package/client/route/PageDefinition.ts +103 -10
- package/client/route/RouteDefinition.ts +44 -3
- package/client/route/RouteGuards.ts +64 -0
- package/client/route/index.ts +1 -0
- package/client/route/resolveRouteModule.ts +8 -0
- package/client/route/routeArgs.ts +9 -0
- package/client/router.ts +11 -0
- package/common/authCookie.ts +13 -0
- package/common/authToken.ts +1 -12
- package/common/index.ts +2 -8
- package/common/routeConvention.ts +2 -0
- package/constant/inputParser.ts +1 -1
- package/dictionary/base.dictionary.ts +8 -0
- package/fetch/appScope.ts +54 -0
- package/fetch/client/fetchClient.ts +46 -29
- package/fetch/index.ts +1 -0
- package/package.json +1 -1
- package/server/mcp/McpApps.ts +44 -50
- package/server/mcp/McpPages.ts +93 -19
- package/server/mcp/McpRouter.ts +14 -11
- package/server/ops/migrateJobs.ts +145 -0
- package/server/ops/opsRoute.ts +39 -7
- package/server/ops/opsToken.ts +7 -2
- package/server/routeElementComposer.tsx +33 -6
- package/server/routeTreeBuilder.ts +5 -0
- package/server/rscClient.tsx +20 -0
- package/server/rscPagePrompts.ts +66 -16
- package/server/rscWorker.tsx +33 -11
- package/server/rscWorkerHost.ts +12 -1
- package/server/webRouter.ts +31 -27
- package/service/predefinedAdaptor/sql/SqlDocumentStore.ts +12 -1
- package/service/predefinedAdaptor/sql/dialect/postgres.ts +4 -3
- package/service/predefinedAdaptor/sql/dialect/sqlite.ts +2 -2
- package/service/predefinedAdaptor/sql/types.ts +2 -0
- package/service/predefinedAdaptor/sql/values.ts +19 -0
- package/signal/mcp/mcpProtocol.ts +7 -0
- package/signal/mcp/pagePrompt.ts +18 -1
- package/types/client/csrLocation.d.ts +3 -7
- package/types/client/csrTypes.d.ts +20 -3
- package/types/client/route/PageDefinition.d.ts +21 -4
- package/types/client/route/RouteDefinition.d.ts +4 -0
- package/types/client/route/RouteGuards.d.ts +15 -0
- package/types/client/route/index.d.ts +1 -0
- package/types/client/route/resolveRouteModule.d.ts +2 -0
- package/types/client/route/routeArgs.d.ts +5 -0
- package/types/client/route/routeBuilders.d.ts +1 -1
- package/types/client/router.d.ts +3 -0
- package/types/common/authCookie.d.ts +5 -0
- package/types/common/authToken.d.ts +0 -5
- package/types/common/index.d.ts +2 -1
- package/types/dictionary/base.dictionary.d.ts +1 -1
- package/types/dictionary/dictionary.d.ts +3 -3
- package/types/fetch/appScope.d.ts +18 -0
- package/types/fetch/client/fetchClient.d.ts +1 -1
- package/types/fetch/index.d.ts +1 -0
- package/types/server/mcp/McpApps.d.ts +14 -8
- package/types/server/mcp/McpPages.d.ts +6 -3
- package/types/server/ops/migrateJobs.d.ts +36 -0
- package/types/server/ops/opsRoute.d.ts +4 -1
- package/types/server/ops/opsToken.d.ts +3 -0
- package/types/server/routeElementComposer.d.ts +10 -1
- package/types/server/rscPagePrompts.d.ts +3 -1
- package/types/server/rscWorkerHost.d.ts +2 -1
- package/types/server/webRouter.d.ts +3 -0
- package/types/service/predefinedAdaptor/sql/dialect/postgres.d.ts +1 -1
- package/types/service/predefinedAdaptor/sql/dialect/sqlite.d.ts +1 -1
- package/types/service/predefinedAdaptor/sql/types.d.ts +2 -0
- package/types/service/predefinedAdaptor/sql/values.d.ts +1 -0
- package/types/signal/mcp/mcpProtocol.d.ts +6 -0
- package/types/signal/mcp/pagePrompt.d.ts +16 -0
- package/types/ui/Layout/FrameSlotPortal.d.ts +1 -1
- package/types/ui/System/App.d.ts +17 -0
- package/types/ui/System/index.d.ts +2 -0
- package/types/ui/recipe/badgeRecipe.d.ts +2 -2
- package/types/ui/recipe/buttonRecipe.d.ts +2 -2
- package/types/vendor/@akanjs/native/packages/cli/src/lib/toolchains.d.ts +1 -1
- package/types/webkit/CsrGuards.d.ts +25 -0
- package/types/webkit/CsrStack.d.ts +6 -0
- package/types/webkit/McpAppBoot.d.ts +32 -0
- package/types/webkit/McpAppBridge.d.ts +10 -1
- package/types/webkit/McpAppFetchBridge.d.ts +3 -1
- package/types/webkit/McpAppView.d.ts +4 -1
- package/types/webkit/RenderLayer.d.ts +1 -3
- package/types/webkit/index.d.ts +1 -0
- package/ui/Layout/BottomInset.tsx +2 -11
- package/ui/Layout/FrameSlotPortal.tsx +1 -3
- package/ui/Layout/Navbar.tsx +7 -22
- package/ui/Layout/TopInset.tsx +3 -4
- package/ui/System/App.tsx +43 -0
- package/ui/System/CSR.tsx +1 -10
- package/ui/System/index.tsx +3 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/toolchains.ts +1 -1
- package/webkit/CsrGuards.ts +112 -0
- package/webkit/CsrRouteTable.ts +8 -12
- package/webkit/CsrStack.ts +29 -1
- package/webkit/McpAppBoot.tsx +129 -0
- package/webkit/McpAppBridge.ts +30 -6
- package/webkit/McpAppFetchBridge.ts +6 -1
- package/webkit/McpAppView.ts +39 -28
- package/webkit/RenderLayer.tsx +98 -41
- package/webkit/bootCsr.tsx +4 -43
- package/webkit/index.ts +1 -0
- package/webkit/useCsrValues.ts +55 -6
- package/webkit/useLocation.ts +1 -2
- package/types/webkit/EmbeddedLayout.d.ts +0 -10
- 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
|
-
|
|
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(
|
package/constant/inputParser.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
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
package/package.json
CHANGED
package/server/mcp/McpApps.ts
CHANGED
|
@@ -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
|
|
17
|
+
/** The single-file view each `.app()` boots from, read from the build that serves the web. */
|
|
18
18
|
export interface McpAppShellSource {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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(
|
|
88
|
-
|
|
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
|
|
94
|
-
const
|
|
95
|
-
if (!shell ||
|
|
96
|
-
const html = await shell.read(
|
|
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
|
|
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
|
}
|
package/server/mcp/McpPages.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import type { Logger } from "akanjs/common";
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
?
|
|
157
|
+
? MCP_SCREEN_REFUSAL.gated
|
|
137
158
|
: run.reason === "not-found"
|
|
138
|
-
?
|
|
139
|
-
:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
309
|
+
#pageToolsOf(listed: PagePromptEntry[]) {
|
|
240
310
|
if (!this.#props.apps) return [];
|
|
241
|
-
return
|
|
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
|
}
|