@guuey/mcp-apps-host 0.12.2 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The post-turn action-STAGING policy (guuey#356 — lifted VERBATIM from the
3
+ * widget's #198/#215/#218 civilization so every mount shares ONE copy).
4
+ *
5
+ * The beat it solves: after a turn completes there is no live turn to feed
6
+ * a card action into — relaying would only surface the "agent not
7
+ * listening" degradation at exactly the moment the agent's own copy
8
+ * invited the tap. Instead, an ALLOWLISTED semantic action with an honest
9
+ * text form stages a human-readable projection into the composer: one
10
+ * Enter sends it as the next turn. Everything else — internal transport
11
+ * rungs, foreign plumbing, shapes with no honest projection — rides the
12
+ * exact pre-#198 relay/stub path, so fallback chains keep receiving the
13
+ * in-band answers their rungs expect and raw plumbing never reaches the
14
+ * composer (guuey#218's prod defect class).
15
+ *
16
+ * A staged answer is deliberately NOT `isError` (guuey#215): queueing is
17
+ * an acceptance, and the error flag fed apps' legacy failure overlays.
18
+ */
19
+ import { type McpToolCallResult } from "./action.js";
20
+ import type { UiActionRequest } from "./action.js";
21
+ /** The staged-acceptance notice a view receives instead of a relay answer. */
22
+ export declare const ACTION_STAGED_MSG = "Queued \u2014 press Send to continue.";
23
+ /**
24
+ * guuey#198: the post-turn action policy's two host callbacks — when no
25
+ * turn is live, a card action stages into the composer instead of dying in
26
+ * the relay's degradation.
27
+ */
28
+ export interface ActionStaging {
29
+ /** A turn is currently in flight — the live relay path applies. */
30
+ isTurnLive: () => boolean;
31
+ /** Prefill the composer with the projection (append, never clobber) and focus it. */
32
+ stage: (text: string) => void;
33
+ }
34
+ /**
35
+ * guuey#218: ONLY an allowlisted semantic action may stage — keyed on the
36
+ * SEMANTIC set ({@link UI_SEMANTIC_ACTION_TOOLS}), never the wider relay
37
+ * allowlist, so a relayable transport rung is never stage-eligible.
38
+ */
39
+ export declare function isStageableAction(name: string): boolean;
40
+ /**
41
+ * The human-readable projection of a card action — what lands in the
42
+ * composer as the user's next message (guuey#198, option (b)). `null`
43
+ * when the action shape has no honest text form (nested/non-primitive
44
+ * arguments): projecting only part of the arguments could stage a
45
+ * MISLEADING selection, so those degrade to the relay/stub path instead.
46
+ *
47
+ * guuey#215: a semantic-envelope call carries the USER'S action inside its
48
+ * arguments (`actionId` + the action's own params), so the projection
49
+ * unwraps the envelope — humanizing the carrier name would stage plumbing,
50
+ * not the user's words. No string `actionId` ⇒ no honest projection ⇒
51
+ * relay path.
52
+ */
53
+ export declare function stagedActionText(name: string, args: unknown): string | null;
54
+ /**
55
+ * Wrap ANY `onCallTool`-shaped hook with the staging policy (guuey#356):
56
+ * post-turn + allowlisted + honest projection → stage and answer the
57
+ * STAGED notice; every other call — mid-turn, non-semantic, or
58
+ * projection-less — delegates to `inner` byte-identically. A mount passes
59
+ * two callbacks ({@link ActionStaging}) and gets the widget's exact
60
+ * ratified behavior; `staging` absent returns `inner` unchanged, so the
61
+ * wrap composes freely at construction sites.
62
+ */
63
+ export declare function withActionStaging(inner: (request: UiActionRequest) => Promise<McpToolCallResult>, staging: ActionStaging | undefined): (request: UiActionRequest) => Promise<McpToolCallResult>;
64
+ //# sourceMappingURL=action-staging.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"action-staging.d.ts","sourceRoot":"","sources":["../src/action-staging.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AACH,OAAO,EAA4B,KAAK,iBAAiB,EAAiC,MAAM,aAAa,CAAC;AAC9G,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAEnD,8EAA8E;AAC9E,eAAO,MAAM,iBAAiB,0CAAqC,CAAC;AAEpE;;;;GAIG;AACH,MAAM,WAAW,aAAa;IAC5B,mEAAmE;IACnE,UAAU,EAAE,MAAM,OAAO,CAAC;IAC1B,qFAAqF;IACrF,KAAK,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CAC/B;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEvD;AAiCD;;;;;;;;;;;;GAYG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAY3E;AAED;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,CAAC,OAAO,EAAE,eAAe,KAAK,OAAO,CAAC,iBAAiB,CAAC,EAC/D,OAAO,EAAE,aAAa,GAAG,SAAS,GACjC,CAAC,OAAO,EAAE,eAAe,KAAK,OAAO,CAAC,iBAAiB,CAAC,CAY1D"}
@@ -0,0 +1,112 @@
1
+ /**
2
+ * The post-turn action-STAGING policy (guuey#356 — lifted VERBATIM from the
3
+ * widget's #198/#215/#218 civilization so every mount shares ONE copy).
4
+ *
5
+ * The beat it solves: after a turn completes there is no live turn to feed
6
+ * a card action into — relaying would only surface the "agent not
7
+ * listening" degradation at exactly the moment the agent's own copy
8
+ * invited the tap. Instead, an ALLOWLISTED semantic action with an honest
9
+ * text form stages a human-readable projection into the composer: one
10
+ * Enter sends it as the next turn. Everything else — internal transport
11
+ * rungs, foreign plumbing, shapes with no honest projection — rides the
12
+ * exact pre-#198 relay/stub path, so fallback chains keep receiving the
13
+ * in-band answers their rungs expect and raw plumbing never reaches the
14
+ * composer (guuey#218's prod defect class).
15
+ *
16
+ * A staged answer is deliberately NOT `isError` (guuey#215): queueing is
17
+ * an acceptance, and the error flag fed apps' legacy failure overlays.
18
+ */
19
+ import { UI_SEMANTIC_ACTION_TOOLS } from "./action.js";
20
+ /** The staged-acceptance notice a view receives instead of a relay answer. */
21
+ export const ACTION_STAGED_MSG = "Queued — press Send to continue.";
22
+ /**
23
+ * guuey#218: ONLY an allowlisted semantic action may stage — keyed on the
24
+ * SEMANTIC set ({@link UI_SEMANTIC_ACTION_TOOLS}), never the wider relay
25
+ * allowlist, so a relayable transport rung is never stage-eligible.
26
+ */
27
+ export function isStageableAction(name) {
28
+ return UI_SEMANTIC_ACTION_TOOLS.has(name);
29
+ }
30
+ /** "selectSlot" → "Select slot"; "book_slot" → "Book slot". */
31
+ function humanizeActionName(name) {
32
+ const words = name
33
+ .replace(/[_-]+/g, " ")
34
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
35
+ .toLowerCase()
36
+ .trim();
37
+ return words.length === 0 ? name : words[0].toUpperCase() + words.slice(1);
38
+ }
39
+ /** The protocol-open wire shape — same guard idiom as action.ts's. */
40
+ function isStructuredArgs(value) {
41
+ return typeof value === "object" && value !== null && !Array.isArray(value);
42
+ }
43
+ function projectAction(name, args) {
44
+ const title = humanizeActionName(name);
45
+ if (args === undefined || args === null)
46
+ return title;
47
+ if (typeof args !== "object" || Array.isArray(args))
48
+ return null;
49
+ const entries = Object.entries(args);
50
+ if (entries.length === 0)
51
+ return title;
52
+ const pairs = [];
53
+ for (const [key, value] of entries) {
54
+ if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") {
55
+ return null;
56
+ }
57
+ pairs.push(`${key} ${String(value)}`);
58
+ }
59
+ return `${title}: ${pairs.join(", ")}`;
60
+ }
61
+ /**
62
+ * The human-readable projection of a card action — what lands in the
63
+ * composer as the user's next message (guuey#198, option (b)). `null`
64
+ * when the action shape has no honest text form (nested/non-primitive
65
+ * arguments): projecting only part of the arguments could stage a
66
+ * MISLEADING selection, so those degrade to the relay/stub path instead.
67
+ *
68
+ * guuey#215: a semantic-envelope call carries the USER'S action inside its
69
+ * arguments (`actionId` + the action's own params), so the projection
70
+ * unwraps the envelope — humanizing the carrier name would stage plumbing,
71
+ * not the user's words. No string `actionId` ⇒ no honest projection ⇒
72
+ * relay path.
73
+ */
74
+ export function stagedActionText(name, args) {
75
+ if (UI_SEMANTIC_ACTION_TOOLS.has(name)) {
76
+ if (!isStructuredArgs(args))
77
+ return null;
78
+ const actionId = args["actionId"];
79
+ if (typeof actionId !== "string" || actionId.length === 0)
80
+ return null;
81
+ const rest = {};
82
+ for (const [key, value] of Object.entries(args)) {
83
+ if (key !== "actionId")
84
+ rest[key] = value;
85
+ }
86
+ return projectAction(actionId, rest);
87
+ }
88
+ return projectAction(name, args);
89
+ }
90
+ /**
91
+ * Wrap ANY `onCallTool`-shaped hook with the staging policy (guuey#356):
92
+ * post-turn + allowlisted + honest projection → stage and answer the
93
+ * STAGED notice; every other call — mid-turn, non-semantic, or
94
+ * projection-less — delegates to `inner` byte-identically. A mount passes
95
+ * two callbacks ({@link ActionStaging}) and gets the widget's exact
96
+ * ratified behavior; `staging` absent returns `inner` unchanged, so the
97
+ * wrap composes freely at construction sites.
98
+ */
99
+ export function withActionStaging(inner, staging) {
100
+ if (staging === undefined)
101
+ return inner;
102
+ return async (request) => {
103
+ if (!staging.isTurnLive() && isStageableAction(request.name)) {
104
+ const text = stagedActionText(request.name, request.arguments);
105
+ if (text !== null) {
106
+ staging.stage(text);
107
+ return { content: [{ type: "text", text: ACTION_STAGED_MSG }] };
108
+ }
109
+ }
110
+ return inner(request);
111
+ };
112
+ }
package/dist/index.d.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  */
6
6
  export { asResourcePayload, asUiResource, blockUiResource, isJsonObject, resourceHtml, scanProviderRawForUiResource, snapshotUiResource, toolResultLocator, toolResultUiResource, uiLocator, type McpUiResourcePayload, } from "./block-ui.js";
7
7
  export { resolveViewMount, snapshotViewMount, toolResultViewMount, type LocatorViewMount, type ResolvedViewMount, type UiResourceReader, type ViewMount, type ViewMountChannel, } from "./card-mount.js";
8
+ export { ACTION_STAGED_MSG, isStageableAction, stagedActionText, withActionStaging, type ActionStaging, } from "./action-staging.js";
8
9
  export { createMcpUiResourceReader, declaredResourceCsp, uiResourceChannel, type CreateMcpUiResourceReaderDeps, type McpResourceReadResult, } from "./reader.js";
9
10
  export type { McpUiResourceCsp } from "@modelcontextprotocol/ext-apps";
10
11
  export { asToolCallResult, createMcpUiActionRelay, unavailableToolCallResult, UI_ACTION_TOOLS, UI_ACTION_UNAVAILABLE_TEXT, UI_SEMANTIC_ACTION_TOOLS, type CreateMcpUiActionRelayDeps, type McpToolCallContent, type McpToolCallResult, type McpToolStructuredContent, type UiActionRequest, } from "./action.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EACL,iBAAiB,EACjB,YAAY,EACZ,eAAe,EACf,YAAY,EACZ,YAAY,EACZ,4BAA4B,EAC5B,kBAAkB,EAClB,iBAAiB,EACjB,oBAAoB,EACpB,SAAS,EACT,KAAK,oBAAoB,GAC1B,MAAM,eAAe,CAAC;AACvB,OAAO,EACL,gBAAgB,EAChB,iBAAiB,EACjB,mBAAmB,EACnB,KAAK,gBAAgB,EACrB,KAAK,iBAAiB,EACtB,KAAK,gBAAgB,EACrB,KAAK,SAAS,EACd,KAAK,gBAAgB,GACtB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EACL,yBAAyB,EACzB,mBAAmB,EACnB,iBAAiB,EACjB,KAAK,6BAA6B,EAClC,KAAK,qBAAqB,GAC3B,MAAM,aAAa,CAAC;AAIrB,YAAY,EAAE,gBAAgB,EAAE,MAAM,gCAAgC,CAAC;AACvE,OAAO,EACL,gBAAgB,EAChB,sBAAsB,EACtB,yBAAyB,EACzB,eAAe,EACf,0BAA0B,EAC1B,wBAAwB,EACxB,KAAK,0BAA0B,EAC/B,KAAK,kBAAkB,EACvB,KAAK,iBAAiB,EACtB,KAAK,wBAAwB,EAC7B,KAAK,eAAe,GACrB,MAAM,aAAa,CAAC;AACrB,OAAO,EACL,gBAAgB,EAChB,oBAAoB,EACpB,oBAAoB,EACpB,qBAAqB,EACrB,eAAe,EACf,gBAAgB,EAChB,iBAAiB,EACjB,eAAe,EACf,eAAe,EACf,oBAAoB,EACpB,KAAK,gBAAgB,EACrB,KAAK,gBAAgB,EACrB,KAAK,cAAc,EACnB,KAAK,gBAAgB,EACrB,KAAK,cAAc,EACnB,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAClB,KAAK,YAAY,EACjB,KAAK,aAAa,EAClB,KAAK,kBAAkB,EACvB,KAAK,aAAa,GACnB,MAAM,yBAAyB,CAAC;AACjC,OAAO,EACL,cAAc,EACd,gBAAgB,EAChB,KAAK,oBAAoB,EACzB,KAAK,aAAa,EAClB,KAAK,aAAa,EAClB,KAAK,cAAc,GACpB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EACL,yBAAyB,EACzB,mBAAmB,EACnB,0BAA0B,EAC1B,6BAA6B,EAC7B,KAAK,yBAAyB,GAC/B,MAAM,mBAAmB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EACL,iBAAiB,EACjB,YAAY,EACZ,eAAe,EACf,YAAY,EACZ,YAAY,EACZ,4BAA4B,EAC5B,kBAAkB,EAClB,iBAAiB,EACjB,oBAAoB,EACpB,SAAS,EACT,KAAK,oBAAoB,GAC1B,MAAM,eAAe,CAAC;AACvB,OAAO,EACL,gBAAgB,EAChB,iBAAiB,EACjB,mBAAmB,EACnB,KAAK,gBAAgB,EACrB,KAAK,iBAAiB,EACtB,KAAK,gBAAgB,EACrB,KAAK,SAAS,EACd,KAAK,gBAAgB,GACtB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EACL,iBAAiB,EACjB,iBAAiB,EACjB,gBAAgB,EAChB,iBAAiB,EACjB,KAAK,aAAa,GACnB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EACL,yBAAyB,EACzB,mBAAmB,EACnB,iBAAiB,EACjB,KAAK,6BAA6B,EAClC,KAAK,qBAAqB,GAC3B,MAAM,aAAa,CAAC;AAIrB,YAAY,EAAE,gBAAgB,EAAE,MAAM,gCAAgC,CAAC;AACvE,OAAO,EACL,gBAAgB,EAChB,sBAAsB,EACtB,yBAAyB,EACzB,eAAe,EACf,0BAA0B,EAC1B,wBAAwB,EACxB,KAAK,0BAA0B,EAC/B,KAAK,kBAAkB,EACvB,KAAK,iBAAiB,EACtB,KAAK,wBAAwB,EAC7B,KAAK,eAAe,GACrB,MAAM,aAAa,CAAC;AACrB,OAAO,EACL,gBAAgB,EAChB,oBAAoB,EACpB,oBAAoB,EACpB,qBAAqB,EACrB,eAAe,EACf,gBAAgB,EAChB,iBAAiB,EACjB,eAAe,EACf,eAAe,EACf,oBAAoB,EACpB,KAAK,gBAAgB,EACrB,KAAK,gBAAgB,EACrB,KAAK,cAAc,EACnB,KAAK,gBAAgB,EACrB,KAAK,cAAc,EACnB,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAClB,KAAK,YAAY,EACjB,KAAK,aAAa,EAClB,KAAK,kBAAkB,EACvB,KAAK,aAAa,GACnB,MAAM,yBAAyB,CAAC;AACjC,OAAO,EACL,cAAc,EACd,gBAAgB,EAChB,KAAK,oBAAoB,EACzB,KAAK,aAAa,EAClB,KAAK,aAAa,EAClB,KAAK,cAAc,GACpB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EACL,yBAAyB,EACzB,mBAAmB,EACnB,0BAA0B,EAC1B,6BAA6B,EAC7B,KAAK,yBAAyB,GAC/B,MAAM,mBAAmB,CAAC"}
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@
5
5
  */
6
6
  export { asResourcePayload, asUiResource, blockUiResource, isJsonObject, resourceHtml, scanProviderRawForUiResource, snapshotUiResource, toolResultLocator, toolResultUiResource, uiLocator, } from "./block-ui.js";
7
7
  export { resolveViewMount, snapshotViewMount, toolResultViewMount, } from "./card-mount.js";
8
+ export { ACTION_STAGED_MSG, isStageableAction, stagedActionText, withActionStaging, } from "./action-staging.js";
8
9
  export { createMcpUiResourceReader, declaredResourceCsp, uiResourceChannel, } from "./reader.js";
9
10
  export { asToolCallResult, createMcpUiActionRelay, unavailableToolCallResult, UI_ACTION_TOOLS, UI_ACTION_UNAVAILABLE_TEXT, UI_SEMANTIC_ACTION_TOOLS, } from "./action.js";
10
11
  export { initializeResult, initialViewHostState, resourceReadResponse, RESOURCES_READ_METHOD, teardownMessage, toolCallResponse, TOOLS_CALL_METHOD, viewHostElapsed, viewHostReceive, diagnoseCspViolation, } from "./view-host-protocol.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guuey/mcp-apps-host",
3
- "version": "0.12.2",
3
+ "version": "0.14.0",
4
4
  "description": "The MCP Apps (SEP-1865) Host role for guuey's chat surfaces — view-mount narrowing across UI channels, ui:// locator rehydration by resources/read, and the sandbox-trust channel contract. Vendor-neutral: any spec-following MCP App mounts through it.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -33,7 +33,7 @@
33
33
  }
34
34
  },
35
35
  "dependencies": {
36
- "@ggui-ai/protocol": "0.11.0",
36
+ "@ggui-ai/protocol": "0.12.0",
37
37
  "@modelcontextprotocol/ext-apps": "^1.7.5",
38
38
  "@silverprotocol/core": "0.5.1"
39
39
  },
@@ -50,7 +50,7 @@
50
50
  "@types/react": "^19.0.0",
51
51
  "react": "^19.0.0",
52
52
  "typescript": "^5.0.0",
53
- "vitest": "^3.0.0"
53
+ "vitest": "^3.2.7"
54
54
  },
55
55
  "publishConfig": {
56
56
  "access": "public"
@@ -0,0 +1,128 @@
1
+ /**
2
+ * The post-turn action-STAGING policy (guuey#356 — lifted VERBATIM from the
3
+ * widget's #198/#215/#218 civilization so every mount shares ONE copy).
4
+ *
5
+ * The beat it solves: after a turn completes there is no live turn to feed
6
+ * a card action into — relaying would only surface the "agent not
7
+ * listening" degradation at exactly the moment the agent's own copy
8
+ * invited the tap. Instead, an ALLOWLISTED semantic action with an honest
9
+ * text form stages a human-readable projection into the composer: one
10
+ * Enter sends it as the next turn. Everything else — internal transport
11
+ * rungs, foreign plumbing, shapes with no honest projection — rides the
12
+ * exact pre-#198 relay/stub path, so fallback chains keep receiving the
13
+ * in-band answers their rungs expect and raw plumbing never reaches the
14
+ * composer (guuey#218's prod defect class).
15
+ *
16
+ * A staged answer is deliberately NOT `isError` (guuey#215): queueing is
17
+ * an acceptance, and the error flag fed apps' legacy failure overlays.
18
+ */
19
+ import { UI_SEMANTIC_ACTION_TOOLS, type McpToolCallResult, type McpToolStructuredContent } from "./action.js";
20
+ import type { UiActionRequest } from "./action.js";
21
+
22
+ /** The staged-acceptance notice a view receives instead of a relay answer. */
23
+ export const ACTION_STAGED_MSG = "Queued — press Send to continue.";
24
+
25
+ /**
26
+ * guuey#198: the post-turn action policy's two host callbacks — when no
27
+ * turn is live, a card action stages into the composer instead of dying in
28
+ * the relay's degradation.
29
+ */
30
+ export interface ActionStaging {
31
+ /** A turn is currently in flight — the live relay path applies. */
32
+ isTurnLive: () => boolean;
33
+ /** Prefill the composer with the projection (append, never clobber) and focus it. */
34
+ stage: (text: string) => void;
35
+ }
36
+
37
+ /**
38
+ * guuey#218: ONLY an allowlisted semantic action may stage — keyed on the
39
+ * SEMANTIC set ({@link UI_SEMANTIC_ACTION_TOOLS}), never the wider relay
40
+ * allowlist, so a relayable transport rung is never stage-eligible.
41
+ */
42
+ export function isStageableAction(name: string): boolean {
43
+ return UI_SEMANTIC_ACTION_TOOLS.has(name);
44
+ }
45
+
46
+ /** "selectSlot" → "Select slot"; "book_slot" → "Book slot". */
47
+ function humanizeActionName(name: string): string {
48
+ const words = name
49
+ .replace(/[_-]+/g, " ")
50
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
51
+ .toLowerCase()
52
+ .trim();
53
+ return words.length === 0 ? name : words[0].toUpperCase() + words.slice(1);
54
+ }
55
+
56
+ /** The protocol-open wire shape — same guard idiom as action.ts's. */
57
+ function isStructuredArgs(value: unknown): value is McpToolStructuredContent {
58
+ return typeof value === "object" && value !== null && !Array.isArray(value);
59
+ }
60
+
61
+ function projectAction(name: string, args: unknown): string | null {
62
+ const title = humanizeActionName(name);
63
+ if (args === undefined || args === null) return title;
64
+ if (typeof args !== "object" || Array.isArray(args)) return null;
65
+ const entries = Object.entries(args);
66
+ if (entries.length === 0) return title;
67
+ const pairs: string[] = [];
68
+ for (const [key, value] of entries) {
69
+ if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") {
70
+ return null;
71
+ }
72
+ pairs.push(`${key} ${String(value)}`);
73
+ }
74
+ return `${title}: ${pairs.join(", ")}`;
75
+ }
76
+
77
+ /**
78
+ * The human-readable projection of a card action — what lands in the
79
+ * composer as the user's next message (guuey#198, option (b)). `null`
80
+ * when the action shape has no honest text form (nested/non-primitive
81
+ * arguments): projecting only part of the arguments could stage a
82
+ * MISLEADING selection, so those degrade to the relay/stub path instead.
83
+ *
84
+ * guuey#215: a semantic-envelope call carries the USER'S action inside its
85
+ * arguments (`actionId` + the action's own params), so the projection
86
+ * unwraps the envelope — humanizing the carrier name would stage plumbing,
87
+ * not the user's words. No string `actionId` ⇒ no honest projection ⇒
88
+ * relay path.
89
+ */
90
+ export function stagedActionText(name: string, args: unknown): string | null {
91
+ if (UI_SEMANTIC_ACTION_TOOLS.has(name)) {
92
+ if (!isStructuredArgs(args)) return null;
93
+ const actionId = args["actionId"];
94
+ if (typeof actionId !== "string" || actionId.length === 0) return null;
95
+ const rest: McpToolStructuredContent = {};
96
+ for (const [key, value] of Object.entries(args)) {
97
+ if (key !== "actionId") rest[key] = value;
98
+ }
99
+ return projectAction(actionId, rest);
100
+ }
101
+ return projectAction(name, args);
102
+ }
103
+
104
+ /**
105
+ * Wrap ANY `onCallTool`-shaped hook with the staging policy (guuey#356):
106
+ * post-turn + allowlisted + honest projection → stage and answer the
107
+ * STAGED notice; every other call — mid-turn, non-semantic, or
108
+ * projection-less — delegates to `inner` byte-identically. A mount passes
109
+ * two callbacks ({@link ActionStaging}) and gets the widget's exact
110
+ * ratified behavior; `staging` absent returns `inner` unchanged, so the
111
+ * wrap composes freely at construction sites.
112
+ */
113
+ export function withActionStaging(
114
+ inner: (request: UiActionRequest) => Promise<McpToolCallResult>,
115
+ staging: ActionStaging | undefined,
116
+ ): (request: UiActionRequest) => Promise<McpToolCallResult> {
117
+ if (staging === undefined) return inner;
118
+ return async (request) => {
119
+ if (!staging.isTurnLive() && isStageableAction(request.name)) {
120
+ const text = stagedActionText(request.name, request.arguments);
121
+ if (text !== null) {
122
+ staging.stage(text);
123
+ return { content: [{ type: "text", text: ACTION_STAGED_MSG }] };
124
+ }
125
+ }
126
+ return inner(request);
127
+ };
128
+ }
package/src/index.ts CHANGED
@@ -26,6 +26,13 @@ export {
26
26
  type ViewMount,
27
27
  type ViewMountChannel,
28
28
  } from "./card-mount.js";
29
+ export {
30
+ ACTION_STAGED_MSG,
31
+ isStageableAction,
32
+ stagedActionText,
33
+ withActionStaging,
34
+ type ActionStaging,
35
+ } from "./action-staging.js";
29
36
  export {
30
37
  createMcpUiResourceReader,
31
38
  declaredResourceCsp,