akanjs 3.0.0-alpha.50 → 3.0.0-alpha.52
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 +103 -0
- package/constant/fieldInfo.ts +32 -0
- package/constant/mask.ts +11 -4
- package/local/apps/serverLifecycle/serverLifecycle-local.db-shm +0 -0
- package/local/apps/serverLifecycle/serverLifecycle-local_solid.db-shm +0 -0
- package/package.json +1 -1
- package/server/akanApp.ts +15 -11
- package/server/akanServer.ts +13 -0
- package/server/mcp/McpDispatcher.ts +42 -2
- package/server/mcp/McpRouter.ts +2 -0
- package/signal/schema/JsonSchemaBuilder.ts +10 -6
- package/store/agent/ScreenReader.ts +36 -6
- package/store/agent/ScreenTarget.ts +13 -7
- package/store/agent/StoreSurfaceSource.ts +1 -1
- package/types/constant/fieldInfo.d.ts +10 -0
- package/types/constant/mask.d.ts +10 -3
- package/types/server/akanServer.d.ts +7 -0
- package/types/server/mcp/McpDispatcher.d.ts +7 -0
- package/types/server/mcp/McpRouter.d.ts +2 -0
- package/types/signal/schema/JsonSchemaBuilder.d.ts +9 -5
- package/types/store/agent/ScreenReader.d.ts +5 -0
- package/types/store/agent/ScreenTarget.d.ts +5 -4
- package/types/ui/Agent/Approval.d.ts +4 -3
- package/types/ui/Agent/Bubble.d.ts +5 -2
- package/types/ui/Agent/Chat.d.ts +17 -1
- package/types/ui/Agent/Composer.d.ts +6 -6
- package/types/ui/Agent/Launcher.d.ts +3 -3
- package/types/ui/Agent/Markdown.d.ts +11 -3
- package/types/ui/Agent/Menu.d.ts +4 -3
- package/types/ui/Agent/Question.d.ts +4 -3
- package/types/ui/Agent/Skip.d.ts +22 -0
- package/types/ui/Agent/index.d.ts +1 -0
- package/types/ui/Agent/markdownBlocks.d.ts +1 -0
- package/types/ui/Agent/useKeyboardInset.d.ts +8 -0
- package/types/ui/UiOverride/context.d.ts +15 -0
- package/types/ui/index.d.ts +13 -0
- package/ui/Agent/Approval.tsx +6 -3
- package/ui/Agent/Bubble.tsx +7 -2
- package/ui/Agent/Chat.tsx +73 -27
- package/ui/Agent/Composer.tsx +27 -10
- package/ui/Agent/Launcher.tsx +5 -2
- package/ui/Agent/Markdown.tsx +32 -15
- package/ui/Agent/Menu.tsx +6 -3
- package/ui/Agent/Question.tsx +6 -3
- package/ui/Agent/Skip.tsx +30 -0
- package/ui/Agent/index.ts +14 -1
- package/ui/Agent/markdownBlocks.ts +7 -5
- package/ui/Agent/useKeyboardInset.ts +26 -0
- package/ui/Popconfirm.tsx +7 -2
- package/ui/Select.tsx +8 -2
- package/ui/UiOverride/context.ts +16 -0
- package/ui/index.ts +15 -0
- package/vendor/use-agentic/AgentSession.ts +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,108 @@
|
|
|
1
1
|
# akanjs
|
|
2
2
|
|
|
3
|
+
## 2.4.2
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 11aa655: feat(constant): remove a relation's target with its owner via `cascade: "remove"`
|
|
8
|
+
|
|
9
|
+
A relation field can now take its target down with it: `image: field(File, { cascade: "remove" })`, arrays
|
|
10
|
+
included. The removal runs through the **target's service**, so the target's own `_postRemove` runs with it —
|
|
11
|
+
that is how removing a model also deletes the file's stored blob or object, with no extra wiring in the owning
|
|
12
|
+
module.
|
|
13
|
+
|
|
14
|
+
Only a relation accepts the option. A `String`, an `ID`, a scalar, and a nested array each fail while the class
|
|
15
|
+
is being built, naming the field: none of them points at a document the framework could remove.
|
|
16
|
+
|
|
17
|
+
Target services resolve lazily, at removal time, so a cascade adds no boot-order edge between two services and a
|
|
18
|
+
cascade cycle cannot fail the boot. They resolve _before_ the parent is touched, so a model cascading into a
|
|
19
|
+
module the app never mounted fails with nothing half-removed.
|
|
20
|
+
|
|
21
|
+
Two limits worth knowing. Nothing checks whether another document still references the same target, so declaring
|
|
22
|
+
`cascade` asserts that the field owns its target exclusively. And query-level removal (`deleteManyByQuery` /
|
|
23
|
+
`updateManyByQuery`) stamps `removedAt` in one atomic update that fires no hooks and therefore no cascade —
|
|
24
|
+
remove documents one at a time when they cascade.
|
|
25
|
+
|
|
26
|
+
- 11aa655: feat(store): invalidate sibling slices on create and refetch them in `Load.Units`
|
|
27
|
+
|
|
28
|
+
A model with more than one parent is listed by more than one slice, and a create could only ever be spliced
|
|
29
|
+
into the slice it was issued from. Creating a `bizDoc` from `bizDocInOrg` left `bizDocListInProject` without
|
|
30
|
+
it, and an RSC navigation back to that page replayed a cached payload whose server-stamped
|
|
31
|
+
`bizDocInitAtInProject` still satisfied `Load.Units`' cache check — so the new document stayed invisible until
|
|
32
|
+
a full reload.
|
|
33
|
+
|
|
34
|
+
`create<Model>` and `create<Model>InForm` now stamp a new per-slice `<model>StaleAt<Suffix>` on every slice
|
|
35
|
+
_except_ the one named by `sliceName`. Whether the new document belongs to a sibling slice is a server-side
|
|
36
|
+
filter decision, so the siblings are marked for revalidation rather than patched optimistically. `Load.Units`
|
|
37
|
+
refetches through `refresh<Model><Suffix>` when its slice's `StaleAt` is newer than its `InitAt`, and a
|
|
38
|
+
completed refresh restamps `InitAt` past `StaleAt` to end the stale state. The refetch is skipped while the
|
|
39
|
+
list is already loading, which also dedups several `Load.Units` mounted on one slice.
|
|
40
|
+
|
|
41
|
+
`Load.Units` also takes a `staleTime` prop, in milliseconds, for age-based revalidation independent of any
|
|
42
|
+
create: `staleTime={0}` always refetches on mount, `staleTime={30_000}` refetches only when the cached data is
|
|
43
|
+
older than 30s. Omitting it leaves the component purely invalidation-driven.
|
|
44
|
+
|
|
45
|
+
Update and remove are unchanged — they already walk every slice, because a document they touch is one the
|
|
46
|
+
cached lists can be searched for by id.
|
|
47
|
+
|
|
48
|
+
- 11aa655: feat(server): add subRoute hosts at runtime with `AKAN_SUB_ROUTE_HOSTS`
|
|
49
|
+
|
|
50
|
+
An app resolved a request Host to a basePath through a map fixed at build time — the domains written into
|
|
51
|
+
`akan.config.ts` plus the generated `<basePath>-<branch>.<serveDomain>`. A platform that mints its hostnames when
|
|
52
|
+
a project is created cannot write either one into the repo, so its subRoute hosts fell through to the root app.
|
|
53
|
+
|
|
54
|
+
`AKAN_SUB_ROUTE_HOSTS` adds to that map at boot, in the same spirit as `AKAN_PUBLIC_BASE_PATHS`:
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
AKAN_SUB_ROUTE_HOSTS="soft=soft-abc.try.akanjs.com,soft.acme.com;office=office-abc.try.akanjs.com"
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Hosts are matched lowercased and without a port, exactly as the built-in ones are. The env mapping is a union with
|
|
61
|
+
the built one, never a replacement, so a tenant's own domains keep working and removing the env restores the
|
|
62
|
+
previous behaviour byte for byte.
|
|
63
|
+
|
|
64
|
+
A basePath the build does not serve is dropped with a warning rather than honoured — the route tree is a build
|
|
65
|
+
output, so accepting one would answer every request under it with a 404 and nothing to explain why. A malformed
|
|
66
|
+
entry is skipped the same way: the value is rendered by a deployment platform, and one bad character must not turn
|
|
67
|
+
into a boot loop across every pod.
|
|
68
|
+
|
|
69
|
+
`x-base-path` is now checked against the basePaths the build serves before it is trusted, matching the check
|
|
70
|
+
`getBasePathFromPathname` already applied to it. An unrecognised value falls through to host matching instead of
|
|
71
|
+
selecting a basePath that resolves to nothing.
|
|
72
|
+
|
|
73
|
+
- 25d5b15: feat(devkit): sync lib `page` trees into apps and honor `pageConfig.devOnly`
|
|
74
|
+
|
|
75
|
+
Apps can opt in with `syncPageLibs` (`true` / lib list) so `akan sync` links each lib's routes under
|
|
76
|
+
`page/(libs)/(<lib>)` — once per basePath when the app has subRoutes. The link is generated and
|
|
77
|
+
gitignored; the lib source stays the edit target. Collision on the resolved route pattern is a
|
|
78
|
+
sync-time error.
|
|
79
|
+
|
|
80
|
+
`pageConfig.devOnly: true` (literal only) keeps a route out of `akan build` while `akan start` still
|
|
81
|
+
serves it. On a `_layout` it excludes the whole subtree. Symlink-aware file ops avoid wiping a synced
|
|
82
|
+
lib when cleaning an app link, and dangling lib links no longer break `getApps`.
|
|
83
|
+
|
|
84
|
+
- 25d5b15: feat(signal): refresh websocket credentials in-session and re-check pubsub rooms
|
|
85
|
+
|
|
86
|
+
Handshake credentials now live on `AppWsData` (headers/cookies only). Clients that hold the token in
|
|
87
|
+
memory send it with `fetch.setJwt(...)`, which forwards an `__auth` frame; the server swaps the
|
|
88
|
+
snapshot synchronously and re-runs each subscribed room's guards, unsubscribing the ones that fail.
|
|
89
|
+
|
|
90
|
+
Guards should read the caller with `context.get("account")` instead of branching on HTTP vs websocket
|
|
91
|
+
transport. Slice-level guards still only wrap generated query/mutation endpoints — a pubsub endpoint
|
|
92
|
+
needs its own `guards` if the room itself must be protected.
|
|
93
|
+
|
|
94
|
+
### Patch Changes
|
|
95
|
+
|
|
96
|
+
- 6d58c7e: add search feature for sqlit database with fts5
|
|
97
|
+
- 25d5b15: fix(ui): refresh EditModal when the hydrated edit payload is stale
|
|
98
|
+
|
|
99
|
+
RSC navigation can replay a cached page tree, and an edit shell hydrated from that payload can show
|
|
100
|
+
arbitrarily old form data. EditModal now treats a cache replay (or a `modelViewAt` older than 60s) as
|
|
101
|
+
stale and refetches via `edit<Model>` while keeping the modal open on the last known id.
|
|
102
|
+
|
|
103
|
+
- 42cf7a2: Normalize unsendable relayed WebSocket close codes to 1001 so Bun's global client WebSocket.close no longer throws InvalidAccessError at the gateway's client-to-upstream relay when a peer disappears without a close frame (code 1006). The upstream-to-client relay applies the same normalization defensively.
|
|
104
|
+
- 04cb46d: Allow the documented wsConnect export during source validation for root layouts, including base-path and grouped root boundaries, while continuing to reject it on nested layouts.
|
|
105
|
+
|
|
3
106
|
## 2.4.1
|
|
4
107
|
|
|
5
108
|
### Patch Changes
|
package/constant/fieldInfo.ts
CHANGED
|
@@ -106,6 +106,13 @@ export interface ConstantFieldProps<
|
|
|
106
106
|
validate?: (value: FieldValue, model: any) => boolean;
|
|
107
107
|
text?: TextFieldRole;
|
|
108
108
|
cascade?: CascadeAction;
|
|
109
|
+
/**
|
|
110
|
+
* Renders on the page, never reaches an agent. Stripped wherever a value is masked for an AI caller — the
|
|
111
|
+
* in-page agent's reads and every MCP result — and left untouched everywhere else, so a `File`'s blur
|
|
112
|
+
* placeholder still ships to `<Image>`. Unlike `hidden`/`secret` this is about cost, not secrecy: a field
|
|
113
|
+
* nothing can answer a question with is pure spend on every turn it rides.
|
|
114
|
+
*/
|
|
115
|
+
visual?: boolean;
|
|
109
116
|
meta?: Metadata;
|
|
110
117
|
}
|
|
111
118
|
export const fieldPresets = ["email", "password", "url"] as const;
|
|
@@ -197,6 +204,7 @@ interface ConstantFieldBuildProps<
|
|
|
197
204
|
validate?: (value: FieldValue, model: any) => boolean;
|
|
198
205
|
text?: TextFieldRole;
|
|
199
206
|
cascade?: CascadeAction;
|
|
207
|
+
visual: boolean;
|
|
200
208
|
modelRef: ConstantModelRef;
|
|
201
209
|
arrDepth: number;
|
|
202
210
|
optArrDepth: number;
|
|
@@ -321,6 +329,7 @@ export class ConstantField<
|
|
|
321
329
|
readonly validate?: (value: FieldValue, model: any) => boolean;
|
|
322
330
|
readonly text?: TextFieldRole;
|
|
323
331
|
readonly cascade?: CascadeAction;
|
|
332
|
+
readonly visual: boolean;
|
|
324
333
|
readonly modelRef: ConstantModelRef;
|
|
325
334
|
readonly arrDepth: number;
|
|
326
335
|
readonly optArrDepth: number;
|
|
@@ -353,6 +362,7 @@ export class ConstantField<
|
|
|
353
362
|
this.validate = props.validate;
|
|
354
363
|
this.text = props.text;
|
|
355
364
|
this.cascade = props.cascade;
|
|
365
|
+
this.visual = props.visual;
|
|
356
366
|
this.modelRef = props.modelRef;
|
|
357
367
|
this.arrDepth = props.arrDepth;
|
|
358
368
|
this.optArrDepth = props.optArrDepth;
|
|
@@ -432,6 +442,7 @@ export class ConstantField<
|
|
|
432
442
|
validate: option.validate,
|
|
433
443
|
text: option.text,
|
|
434
444
|
cascade: option.cascade,
|
|
445
|
+
visual: option.visual ?? false,
|
|
435
446
|
modelRef,
|
|
436
447
|
arrDepth: arrDepth,
|
|
437
448
|
optArrDepth: optArrDepth,
|
|
@@ -478,6 +489,7 @@ export class ConstantField<
|
|
|
478
489
|
validate: this.validate,
|
|
479
490
|
text: this.text,
|
|
480
491
|
cascade: this.cascade,
|
|
492
|
+
visual: this.visual,
|
|
481
493
|
modelRef: this.modelRef,
|
|
482
494
|
arrDepth: this.arrDepth,
|
|
483
495
|
optArrDepth: this.optArrDepth,
|
|
@@ -530,6 +542,26 @@ export const field = <
|
|
|
530
542
|
fieldType: "property",
|
|
531
543
|
});
|
|
532
544
|
|
|
545
|
+
/**
|
|
546
|
+
* A stored property the page renders and an agent never sees — `field(value, { visual: true })`, spelled short
|
|
547
|
+
* because the reason to reach for it is always the same one. A blur placeholder, a rendered HTML body, a
|
|
548
|
+
* serialized geometry: real data the screen needs, and hundreds of tokens per record that no question is answered
|
|
549
|
+
* from. It stays a plain `property` everywhere else, so persistence, search, forms and the page response are
|
|
550
|
+
* untouched.
|
|
551
|
+
*/
|
|
552
|
+
field.visual = <
|
|
553
|
+
ExplicitType,
|
|
554
|
+
Value extends ConstantFieldTypeInput = PlainTypeToFieldType<ExplicitType>,
|
|
555
|
+
MapValue = Value extends MapConstructor ? typeof PrimitiveScalar : never,
|
|
556
|
+
>(
|
|
557
|
+
value: Value,
|
|
558
|
+
option: FieldOption<Value, MapValue> = {},
|
|
559
|
+
) =>
|
|
560
|
+
new FieldInfo<"property", Value, ExplicitType, MapValue>(value, {
|
|
561
|
+
...option,
|
|
562
|
+
fieldType: "property",
|
|
563
|
+
visual: true,
|
|
564
|
+
});
|
|
533
565
|
field.hidden = <
|
|
534
566
|
ExplicitType,
|
|
535
567
|
Value extends ConstantFieldTypeInput = PlainTypeToFieldType<ExplicitType>,
|
package/constant/mask.ts
CHANGED
|
@@ -15,6 +15,7 @@ interface MaskField {
|
|
|
15
15
|
fieldType?: string;
|
|
16
16
|
isClass?: boolean;
|
|
17
17
|
modelRef?: MaskModel;
|
|
18
|
+
visual?: boolean;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
export const maskFieldsOf = (model: MaskModel): Record<string, MaskField> | null => {
|
|
@@ -22,7 +23,12 @@ export const maskFieldsOf = (model: MaskModel): Record<string, MaskField> | null
|
|
|
22
23
|
return fields && typeof fields === "object" ? (fields as Record<string, MaskField>) : null;
|
|
23
24
|
};
|
|
24
25
|
|
|
25
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* The `hidden` and `secret` field names of `model` that `value` still carries populated.
|
|
28
|
+
*
|
|
29
|
+
* `visual` is deliberately not among them. A refusal here means a value must not be published at all, and a blur
|
|
30
|
+
* placeholder is not a secret — it is merely not worth its tokens, which masking answers by dropping it.
|
|
31
|
+
*/
|
|
26
32
|
export const leakingFieldsOf = (model: MaskModel, value: Record<string, unknown>): string[] => {
|
|
27
33
|
const fields = maskFieldsOf(model);
|
|
28
34
|
if (!fields) return [];
|
|
@@ -32,8 +38,9 @@ export const leakingFieldsOf = (model: MaskModel, value: Record<string, unknown>
|
|
|
32
38
|
};
|
|
33
39
|
|
|
34
40
|
/**
|
|
35
|
-
* Strips what a model marks `hidden` or `
|
|
36
|
-
* happens to still carry.
|
|
41
|
+
* Strips what a model marks `hidden`, `secret`, or `visual`, by the model the caller names rather than by the one
|
|
42
|
+
* the value happens to still carry. The first two are secrecy and the third is cost, but the answer is the same
|
|
43
|
+
* one — leave the field out — and this is the only place every AI-facing read already passes through.
|
|
37
44
|
*
|
|
38
45
|
* That distinction is the whole point. A check that reads the class off the value can only mask what arrives as an
|
|
39
46
|
* instance, so a `{ ...doc }` spread, a `toJSON()`, an `immerify()`, or a round-trip through `JSON.stringify` reaches
|
|
@@ -53,7 +60,7 @@ export const mask = (model: MaskModel, value: unknown): unknown => {
|
|
|
53
60
|
const source = value as Record<string, unknown>;
|
|
54
61
|
const masked: Record<string, unknown> = {};
|
|
55
62
|
for (const [key, field] of Object.entries(fields)) {
|
|
56
|
-
if (field.fieldType === "hidden" || field.fieldType === "secret" || !(key in source)) continue;
|
|
63
|
+
if (field.fieldType === "hidden" || field.fieldType === "secret" || field.visual || !(key in source)) continue;
|
|
57
64
|
masked[key] = field.isClass && field.modelRef ? mask(field.modelRef, source[key]) : source[key];
|
|
58
65
|
}
|
|
59
66
|
return masked;
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
package/server/akanApp.ts
CHANGED
|
@@ -45,6 +45,19 @@ type GatewayUpstream = {
|
|
|
45
45
|
ws?: Extract<AkanUpstream, { type: "tcp" }>;
|
|
46
46
|
};
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Received-only close codes cannot be sent in a close frame. The gateway deliberately normalizes
|
|
50
|
+
* every unsendable code, including semantically distinct 1005 and 1006 events, to 1001 in both
|
|
51
|
+
* relay directions. In particular, Bun's global client `WebSocket.close()` throws an
|
|
52
|
+
* InvalidAccessError for these codes at the client-to-upstream relay; normalization at the
|
|
53
|
+
* upstream-to-client `Bun.ServerWebSocket.close()` relay is defensive and keeps behavior symmetric.
|
|
54
|
+
*/
|
|
55
|
+
const relayableCloseCode = (code: number): number => {
|
|
56
|
+
if (code >= 3000 && code <= 4999) return code;
|
|
57
|
+
if (code >= 1000 && code <= 1014 && code !== 1004 && code !== 1005 && code !== 1006) return code;
|
|
58
|
+
return 1001;
|
|
59
|
+
};
|
|
60
|
+
|
|
48
61
|
/** Options for the Akan gateway that launches child server replicas and listens for traffic. */
|
|
49
62
|
export interface AkanAppOptions {
|
|
50
63
|
replica?: number | string;
|
|
@@ -602,9 +615,7 @@ export class AkanApp {
|
|
|
602
615
|
if (result === 0) upstream.close();
|
|
603
616
|
});
|
|
604
617
|
upstream.addEventListener("close", (event) => {
|
|
605
|
-
|
|
606
|
-
if (code === undefined) ws.close();
|
|
607
|
-
else ws.close(code, event.reason);
|
|
618
|
+
ws.close(relayableCloseCode(event.code), event.reason);
|
|
608
619
|
});
|
|
609
620
|
upstream.addEventListener("error", () => ws.close(1011, "upstream websocket error"));
|
|
610
621
|
Object.assign(ws.data, { pending });
|
|
@@ -625,15 +636,8 @@ export class AkanApp {
|
|
|
625
636
|
else ws.data.pending?.push(payload as string | ArrayBuffer);
|
|
626
637
|
}
|
|
627
638
|
|
|
628
|
-
static #sendableCloseCode(code: number): number | undefined {
|
|
629
|
-
const sendable = (code >= 1000 && code <= 1014 && (code < 1004 || code > 1006)) || (code >= 3000 && code <= 4999);
|
|
630
|
-
return sendable ? code : undefined;
|
|
631
|
-
}
|
|
632
|
-
|
|
633
639
|
#handleWsClose(ws: Bun.ServerWebSocket<GatewayWsData>, code: number, reason: string) {
|
|
634
|
-
|
|
635
|
-
if (upstreamCode === undefined) ws.data.upstream.close();
|
|
636
|
-
else ws.data.upstream.close(upstreamCode, reason);
|
|
640
|
+
ws.data.upstream.close(relayableCloseCode(code), reason);
|
|
637
641
|
const child = this.#children.get(ws.data.childIdx);
|
|
638
642
|
if (child) child.metrics.activeWebSockets = Math.max(0, (child.metrics.activeWebSockets ?? 1) - 1);
|
|
639
643
|
}
|
package/server/akanServer.ts
CHANGED
|
@@ -80,6 +80,13 @@ export interface McpServerOption {
|
|
|
80
80
|
* the document is built once at boot and cached by clients, and it is read by a model rather than by a person.
|
|
81
81
|
*/
|
|
82
82
|
language?: string;
|
|
83
|
+
/**
|
|
84
|
+
* Whether a structured result also ships as serialized JSON in the text block, `true` by default because that
|
|
85
|
+
* is what the spec asks of a server for clients that predate `structuredContent`. It is also a flat doubling:
|
|
86
|
+
* every model-returning tool sends its whole payload twice, so a deployment whose clients read the structured
|
|
87
|
+
* half turns this off and halves what each of those calls costs the model. `AKAN_MCP_LEGACY_TEXT=false`.
|
|
88
|
+
*/
|
|
89
|
+
legacyTextBlock?: boolean;
|
|
83
90
|
/** OAuth resource-server identity: which issuers a client may authenticate with, and the scopes to demand. */
|
|
84
91
|
auth?: McpAuthOption;
|
|
85
92
|
}
|
|
@@ -622,6 +629,11 @@ export class AkanServer {
|
|
|
622
629
|
return !names.some((name) => process.env[name] === "false" || process.env[name] === "0");
|
|
623
630
|
}
|
|
624
631
|
|
|
632
|
+
/** Named rather than defaulted: an absent env must leave the option unset so a value written in code still wins. */
|
|
633
|
+
static #isEnvOff(...names: string[]) {
|
|
634
|
+
return names.some((name) => process.env[name] === "false" || process.env[name] === "0");
|
|
635
|
+
}
|
|
636
|
+
|
|
625
637
|
/** Both differ per environment, so they belong in env rather than in the app's source alongside the switch. */
|
|
626
638
|
static #mcpAuthFromEnv(): McpAuthOption {
|
|
627
639
|
const authorizationServers = AkanServer.#envList("AKAN_MCP_AUTH_SERVERS");
|
|
@@ -657,6 +669,7 @@ export class AkanServer {
|
|
|
657
669
|
...(allowedOrigins?.length ? { allowedOrigins } : {}),
|
|
658
670
|
...(Number.isInteger(pageSize) && pageSize > 0 ? { pageSize } : {}),
|
|
659
671
|
...(process.env.AKAN_MCP_LANGUAGE ? { language: process.env.AKAN_MCP_LANGUAGE } : {}),
|
|
672
|
+
...(AkanServer.#isEnvOff("AKAN_MCP_LEGACY_TEXT") ? { legacyTextBlock: false } : {}),
|
|
660
673
|
};
|
|
661
674
|
}
|
|
662
675
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type BackendEnv, ENDPOINT_META } from "akanjs/base";
|
|
2
2
|
import { Logger } from "akanjs/common";
|
|
3
|
+
import { ConstantRegistry, mask } from "akanjs/constant";
|
|
3
4
|
import { DictionaryLookup } from "akanjs/dictionary";
|
|
4
5
|
import { NoDocumentError } from "akanjs/document";
|
|
5
6
|
import type { InjectRegistry, LiveRegistry } from "akanjs/service";
|
|
@@ -42,12 +43,20 @@ interface McpDispatcherProps {
|
|
|
42
43
|
middleware: Map<string, MiddlewareCls>;
|
|
43
44
|
/** The one language error text is resolved in, matching the catalogue the client was handed. */
|
|
44
45
|
language?: string;
|
|
46
|
+
/**
|
|
47
|
+
* Whether a structured result also ships as serialized JSON in the text block. `true` by default, which is what
|
|
48
|
+
* the spec asks for; `false` halves what every model-returning tool costs.
|
|
49
|
+
*/
|
|
50
|
+
legacyTextBlock?: boolean;
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
/** Executes one MCP tool call or resource read through the ordinary signal pipeline. */
|
|
48
54
|
export class McpDispatcher {
|
|
49
55
|
static readonly logger = new Logger("McpDispatcher");
|
|
50
56
|
|
|
57
|
+
/** What the text block says when the serialized duplicate is off. Read by a model, so English. */
|
|
58
|
+
static readonly structuredNote = "The result is in this call's structuredContent.";
|
|
59
|
+
|
|
51
60
|
readonly #props: McpDispatcherProps;
|
|
52
61
|
#endpoints: Map<string, { endpointInfo: EndpointInfo; endpoint: Endpoint }> | null = null;
|
|
53
62
|
/**
|
|
@@ -68,10 +77,10 @@ export class McpDispatcher {
|
|
|
68
77
|
const found = this.#index().get(exposed.key);
|
|
69
78
|
if (!found) return McpDispatcher.#failure(`Tool "${exposed.key}" is declared but not mounted on this server.`);
|
|
70
79
|
try {
|
|
71
|
-
const value = await this.#exec(exposed.key, found, args, req);
|
|
80
|
+
const value = McpDispatcher.#readable(exposed, await this.#exec(exposed.key, found, args, req));
|
|
72
81
|
const structuredContent = McpDocument.structuredContent(exposed.endpoint, value);
|
|
73
82
|
return {
|
|
74
|
-
content:
|
|
83
|
+
content: this.#content(structuredContent, value),
|
|
75
84
|
...(structuredContent === undefined ? {} : { structuredContent }),
|
|
76
85
|
isError: false,
|
|
77
86
|
};
|
|
@@ -223,6 +232,19 @@ export class McpDispatcher {
|
|
|
223
232
|
return status && status < 500 ? McpErrorCode.invalidParams : McpErrorCode.internal;
|
|
224
233
|
}
|
|
225
234
|
|
|
235
|
+
/**
|
|
236
|
+
* A structured result rides twice by default: once as `structuredContent`, once as the same JSON here, which is
|
|
237
|
+
* what the spec asks of a server for clients that predate the structured field. Every model return therefore
|
|
238
|
+
* costs the model twice what it carries, and `legacyTextBlock: false` is the deployment that has decided its
|
|
239
|
+
* clients read the structured half — the pointer keeps `content` non-empty, since a client that renders
|
|
240
|
+
* `content[0].text` and finds nothing shows an empty answer rather than a missing one.
|
|
241
|
+
*/
|
|
242
|
+
#content(structuredContent: unknown, value: unknown): McpToolResult["content"] {
|
|
243
|
+
if (structuredContent !== undefined && this.#props.legacyTextBlock === false)
|
|
244
|
+
return [{ type: "text", text: McpDispatcher.structuredNote }];
|
|
245
|
+
return [{ type: "text", text: McpDispatcher.#text(structuredContent, value) }];
|
|
246
|
+
}
|
|
247
|
+
|
|
226
248
|
/**
|
|
227
249
|
* The text block every result carries, whether or not a structured one goes with it.
|
|
228
250
|
*
|
|
@@ -239,6 +261,24 @@ export class McpDispatcher {
|
|
|
239
261
|
return JSON.stringify(structuredContent ?? value) ?? "null";
|
|
240
262
|
}
|
|
241
263
|
|
|
264
|
+
/**
|
|
265
|
+
* Strips what the return model marks `visual` — a field the page renders and no question is answered from.
|
|
266
|
+
*
|
|
267
|
+
* Done here rather than in `resolveReturn`, which every ordinary HTTP response also passes through: the point of
|
|
268
|
+
* a `visual` field is that a browser still receives it. MCP results reach an agent and nothing else, so this is
|
|
269
|
+
* where the model's own declaration is honoured, and it is the same `mask` the in-page agent's reads use.
|
|
270
|
+
*/
|
|
271
|
+
static #readable(exposed: McpExposedEndpoint, value: unknown): unknown {
|
|
272
|
+
const { refName, modelType } = exposed.endpoint.returns;
|
|
273
|
+
if (!modelType) return value;
|
|
274
|
+
try {
|
|
275
|
+
return mask(ConstantRegistry.getModelRef(refName, modelType), value);
|
|
276
|
+
} catch {
|
|
277
|
+
|
|
278
|
+
return value;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
242
282
|
static #failure(message: string): McpToolResult {
|
|
243
283
|
return { content: [{ type: "text", text: message }], isError: true };
|
|
244
284
|
}
|
package/server/mcp/McpRouter.ts
CHANGED
|
@@ -45,6 +45,8 @@ export interface McpRouterProps {
|
|
|
45
45
|
* rarely sees. Defaults to `en`, falling back to the first registered language when the app has no `en`.
|
|
46
46
|
*/
|
|
47
47
|
language?: string;
|
|
48
|
+
/** Whether a structured result also ships as serialized JSON in the text block. Default `true`. */
|
|
49
|
+
legacyTextBlock?: boolean;
|
|
48
50
|
auth?: McpAuthOption;
|
|
49
51
|
}
|
|
50
52
|
|
|
@@ -14,11 +14,15 @@ export interface JsonSchemaBuilderOptions {
|
|
|
14
14
|
|
|
15
15
|
export interface JsonSchemaModelOptions {
|
|
16
16
|
/**
|
|
17
|
-
* Drops `hidden` and `
|
|
18
|
-
* them describes a value the caller can never read — and on a model like `user` the names
|
|
19
|
-
* leak: `password`, `accountId`, `phone` published as readable properties of the model. This
|
|
20
|
-
* field the framework blocks on every value path is still visible, so it is scoped to schemas
|
|
21
|
-
* *response*. A request body is a different shape and legitimately carries
|
|
17
|
+
* Drops `hidden`, `secret`, and `visual` fields. `SignalContext.resolveReturn` strips the first two from every
|
|
18
|
+
* response, so naming them describes a value the caller can never read — and on a model like `user` the names
|
|
19
|
+
* are themselves the leak: `password`, `accountId`, `phone` published as readable properties of the model. This
|
|
20
|
+
* is the one place a field the framework blocks on every value path is still visible, so it is scoped to schemas
|
|
21
|
+
* that describe a *response*. A request body is a different shape and legitimately carries all three.
|
|
22
|
+
*
|
|
23
|
+
* `visual` is dropped here because it is dropped from the payload an AI caller receives, and a schema that
|
|
24
|
+
* promises a field the value omits is worse than one that never named it: a non-optional visual field would be
|
|
25
|
+
* listed `required` and a validating client would refuse the whole result.
|
|
22
26
|
*/
|
|
23
27
|
readable?: boolean;
|
|
24
28
|
}
|
|
@@ -56,7 +60,7 @@ export class JsonSchemaBuilder {
|
|
|
56
60
|
const required: string[] = [];
|
|
57
61
|
for (const [key, field] of Object.entries(fields)) {
|
|
58
62
|
const props = field.getProps();
|
|
59
|
-
if (readable && (props.fieldType === "hidden" || props.fieldType === "secret")) continue;
|
|
63
|
+
if (readable && (props.fieldType === "hidden" || props.fieldType === "secret" || props.visual)) continue;
|
|
60
64
|
properties[key] = this.#field(field);
|
|
61
65
|
if (!props.nullable) required.push(key);
|
|
62
66
|
}
|
|
@@ -51,6 +51,11 @@ const blockTags = new Set([
|
|
|
51
51
|
* agent's own UI is marked `data-agent-ui` and skipped, so a turn never re-reads its own transcript, and a
|
|
52
52
|
* password value is never read — the screen shows dots, so the DOM holds more than the user sees.
|
|
53
53
|
*
|
|
54
|
+
* A region the app marks `data-agent-skip` — what `Agent.Skip` renders — costs a `[skipped: <name>]` line instead
|
|
55
|
+
* of its text. It stands in the output rather than vanishing because a deleted region reads as an absent one, and
|
|
56
|
+
* an agent asked about a footer it never saw answers that the page has none. Naming the region as `section` reads
|
|
57
|
+
* it: the marker is what the default read leaves out, not a wall.
|
|
58
|
+
*
|
|
54
59
|
* A heading carries `(#anchor)` whenever it opens a container that has an id or a scope path, because that is the
|
|
55
60
|
* name `readScreen({ section })` and `highlight` take: printing the text without the name leaves an agent
|
|
56
61
|
* guessing at a slug. For the same reason a truncated read ends with the headings below the cut instead of only
|
|
@@ -64,7 +69,7 @@ export class ScreenReader {
|
|
|
64
69
|
const reader = new ScreenReader();
|
|
65
70
|
const title = document.title.trim();
|
|
66
71
|
if (title) reader.#lines.push(`Page: ${title}`);
|
|
67
|
-
reader.#walk(root ?? document.body);
|
|
72
|
+
reader.#walk(root ?? document.body, true);
|
|
68
73
|
reader.#flush();
|
|
69
74
|
return reader.#text() || "The page is rendering nothing readable.";
|
|
70
75
|
}
|
|
@@ -78,10 +83,10 @@ export class ScreenReader {
|
|
|
78
83
|
if (typeof document === "undefined") return "No rendered document is available.";
|
|
79
84
|
const reader = new ScreenReader();
|
|
80
85
|
const container = ScreenReader.#sectionOf(heading, root);
|
|
81
|
-
if (container) reader.#walk(container);
|
|
86
|
+
if (container) reader.#walk(container, true);
|
|
82
87
|
else {
|
|
83
88
|
const level = headingLevels[heading.tagName.toUpperCase() as keyof typeof headingLevels] ?? 1;
|
|
84
|
-
reader.#walk(heading);
|
|
89
|
+
reader.#walk(heading, true);
|
|
85
90
|
let next = heading.nextElementSibling;
|
|
86
91
|
while (next && !ScreenReader.#stops(next, level)) {
|
|
87
92
|
reader.#walk(next);
|
|
@@ -123,6 +128,17 @@ export class ScreenReader {
|
|
|
123
128
|
return "";
|
|
124
129
|
}
|
|
125
130
|
|
|
131
|
+
/**
|
|
132
|
+
* `checkVisibility()` reports whether the element has a layout box, and a `display: contents` wrapper has none
|
|
133
|
+
* while its children do — so Chrome answers false for a wrapper the user is looking straight at, and skipping it
|
|
134
|
+
* would drop that whole subtree (happy-dom answers true, so no DOM test sees the difference). Reading through it
|
|
135
|
+
* is safe only because the walk is top-down: a `display: none` ancestor bails on its own computed display first.
|
|
136
|
+
*/
|
|
137
|
+
static #rendered(el: HTMLElement) {
|
|
138
|
+
if (typeof el.checkVisibility !== "function" || el.checkVisibility()) return true;
|
|
139
|
+
return getComputedStyle(el).display === "contents";
|
|
140
|
+
}
|
|
141
|
+
|
|
126
142
|
static #stops(el: Element, level: number) {
|
|
127
143
|
const found = headingLevels[el.tagName.toUpperCase() as keyof typeof headingLevels];
|
|
128
144
|
return !!found && found <= level;
|
|
@@ -141,7 +157,7 @@ export class ScreenReader {
|
|
|
141
157
|
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
|
142
158
|
const el = node as HTMLElement;
|
|
143
159
|
const tag = el.tagName.toUpperCase();
|
|
144
|
-
if (skipTags.has(tag) || el.hasAttribute("data-agent-ui")) return;
|
|
160
|
+
if (skipTags.has(tag) || el.hasAttribute("data-agent-ui") || el.hasAttribute("data-agent-skip")) return;
|
|
145
161
|
const level = headingLevels[tag as keyof typeof headingLevels];
|
|
146
162
|
if (level) {
|
|
147
163
|
const anchor = ScreenReader.anchorOf(el);
|
|
@@ -163,7 +179,15 @@ export class ScreenReader {
|
|
|
163
179
|
: `${kept}${note}`;
|
|
164
180
|
}
|
|
165
181
|
|
|
166
|
-
|
|
182
|
+
/** What stands where a skipped region was: its own name, and the anchor `section` takes to read it on request. */
|
|
183
|
+
#mark(el: HTMLElement, label: string) {
|
|
184
|
+
const anchor = el.getAttribute("data-agent-scope") ?? el.getAttribute("data-agent-zone") ?? el.id;
|
|
185
|
+
this.#flush();
|
|
186
|
+
this.#buffer = `[skipped: ${label || el.tagName.toLowerCase()}${anchor ? ` (#${anchor})` : ""}]`;
|
|
187
|
+
this.#flush();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
#walk(node: Node, isRoot = false): void {
|
|
167
191
|
if (this.#length > ScreenReader.limit * 2) {
|
|
168
192
|
this.#outline(node);
|
|
169
193
|
return;
|
|
@@ -179,7 +203,13 @@ export class ScreenReader {
|
|
|
179
203
|
if (skipTags.has(tag)) return;
|
|
180
204
|
if (el.hasAttribute("data-agent-ui") || el.hasAttribute("hidden") || el.getAttribute("aria-hidden") === "true")
|
|
181
205
|
return;
|
|
182
|
-
if (
|
|
206
|
+
if (!ScreenReader.#rendered(el)) return;
|
|
207
|
+
|
|
208
|
+
const skipped = isRoot ? null : el.getAttribute("data-agent-skip");
|
|
209
|
+
if (skipped !== null) {
|
|
210
|
+
this.#mark(el, skipped);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
183
213
|
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") {
|
|
184
214
|
this.#control(el, tag);
|
|
185
215
|
return;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ScreenReader } from "./ScreenReader";
|
|
2
2
|
|
|
3
|
-
const containerAttrs = ["data-agent-zone", "data-agent-scope"] as const;
|
|
3
|
+
const containerAttrs = ["data-agent-zone", "data-agent-scope", "data-agent-skip"] as const;
|
|
4
4
|
const controlAttrs = ["data-akan-action", "data-akan-state"] as const;
|
|
5
5
|
const headingSelector = "h1, h2, h3, h4, h5, h6";
|
|
6
6
|
const nameCap = 40;
|
|
@@ -9,9 +9,9 @@ const nameCap = 40;
|
|
|
9
9
|
* Resolves a name the agent read on screen to the element it names.
|
|
10
10
|
*
|
|
11
11
|
* Four vocabularies, every one of them something already on the screen rather than a selector the model invented:
|
|
12
|
-
* the `data-akan-action` / `data-akan-state` annotation a control carries and `readScreen` prints beside it,
|
|
13
|
-
* `Agent.Zone
|
|
14
|
-
* **heading by its own text**.
|
|
12
|
+
* the `data-akan-action` / `data-akan-state` annotation a control carries and `readScreen` prints beside it, a
|
|
13
|
+
* container — an `Agent.Zone`, a `useScreenScope` scope, or an `Agent.Skip` region named by the marker left in its
|
|
14
|
+
* place — a plain element id, what a docs slide is addressed by, and, last, a **heading by its own text**.
|
|
15
15
|
*
|
|
16
16
|
* A heading is matched on letters and digits alone, so the slug an agent naturally writes for a heading it read
|
|
17
17
|
* ("images-and-public-env") finds "Images And Public Env". That tolerance is for headings only: a heading is a
|
|
@@ -20,7 +20,8 @@ const nameCap = 40;
|
|
|
20
20
|
*
|
|
21
21
|
* Nothing hidden ever resolves, by the same rule `readScreen` skips it. A collapsed panel or an off-variant
|
|
22
22
|
* duplicate is not what the user is looking at, and scrolling to one flashes a ring nobody can see — which reads
|
|
23
|
-
* as the tool being broken rather than as a miss.
|
|
23
|
+
* as the tool being broken rather than as a miss. A `display: contents` wrapper does not resolve either, even
|
|
24
|
+
* though the reader now reads through it: it has no box, so `scrollIntoView` has nothing to scroll to.
|
|
24
25
|
*/
|
|
25
26
|
export class ScreenTarget {
|
|
26
27
|
static container(name: string, root?: HTMLElement | null): HTMLElement | null {
|
|
@@ -28,8 +29,13 @@ export class ScreenTarget {
|
|
|
28
29
|
if (!scope || !name) return null;
|
|
29
30
|
if (ScreenTarget.#named(scope, containerAttrs, name)) return scope;
|
|
30
31
|
const escaped = CSS.escape(name);
|
|
31
|
-
const selector =
|
|
32
|
-
|
|
32
|
+
const selector = containerAttrs.map((attr) => `[${attr}="${escaped}"]`).join(", ");
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
ScreenTarget.#first(scope, selector) ??
|
|
36
|
+
[...scope.querySelectorAll<HTMLElement>("[id]")].find((el) => el.id === name && ScreenTarget.#visible(el)) ??
|
|
37
|
+
null
|
|
38
|
+
);
|
|
33
39
|
}
|
|
34
40
|
|
|
35
41
|
static control(name: string, root?: HTMLElement | null): HTMLElement | null {
|
|
@@ -107,7 +107,7 @@ export class StoreSurfaceSource implements SurfaceSource {
|
|
|
107
107
|
section: {
|
|
108
108
|
type: "string",
|
|
109
109
|
description:
|
|
110
|
-
"One region of the screen: a heading's anchor as readScreen prints it, the heading's own text,
|
|
110
|
+
"One region of the screen: a heading's anchor as readScreen prints it, the heading's own text, a scope path from the screen context, or the name in a `[skipped: name]` marker. Omit to read all of it.",
|
|
111
111
|
},
|
|
112
112
|
},
|
|
113
113
|
additionalProperties: false,
|