@runsnative/mcp-server 0.6.0 → 0.7.1

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,245 @@
1
+ /**
2
+ * The marker-card MCP App shell (JUNE-623) — the lasso round-trip finale.
3
+ *
4
+ * Fourth consumer of the JUNE-618 packaging seam (see gathering-card.ts for
5
+ * the first). Static shell: no per-call data is baked in. The host delivers
6
+ * the tool result (the capture's structural head) via the MCP Apps
7
+ * tool-result notification; the app script below renders the card
8
+ * client-side with DOM APIs (textContent only — a user-authored `note` can
9
+ * never parse as markup).
10
+ *
11
+ * Push→pull inversion (JUNE-623 comment 1, founder-normative): the pixel
12
+ * crop is NOT inlined in the tool result. The thumbnail slot starts empty;
13
+ * "Show the pixels" fetches it on demand via fetch_marker_crop.
14
+ */
15
+ import { packageSurface } from './package-surface.js';
16
+ export const MARKER_CARD_URI = 'ui://runsnative/marker-card.html';
17
+ const CARD_CSS = `
18
+ @layer rn-shell, primitives, semantics, themes;
19
+ @layer rn-shell {
20
+ :root {
21
+ --run-color-text-primary: #1e293b;
22
+ --run-color-text-secondary: #64748b;
23
+ --run-color-surface-default: #ffffff;
24
+ --run-color-surface-subtle: #f8fafc;
25
+ --run-color-surface-canvas: transparent;
26
+ --run-color-border-default: #e2e8f0;
27
+ --run-color-focus-ring: #2563eb;
28
+ --run-font-size-s: 14px;
29
+ --run-font-size-m: 16px;
30
+ }
31
+ }
32
+ * { box-sizing: border-box; margin: 0; padding: 0; }
33
+ body {
34
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
35
+ color: var(--run-color-text-primary);
36
+ background: var(--run-color-surface-canvas);
37
+ padding: 12px;
38
+ }
39
+ .marker-card {
40
+ max-width: 440px;
41
+ border: 1px solid var(--run-color-border-default);
42
+ border-radius: 12px;
43
+ background: var(--run-color-surface-default);
44
+ padding: 20px;
45
+ display: none;
46
+ flex-direction: column;
47
+ gap: 10px;
48
+ }
49
+ .marker-card.is-ready { display: flex; }
50
+ .card-inputs {
51
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
52
+ font-size: 12px;
53
+ color: var(--run-color-text-secondary);
54
+ background: var(--run-color-surface-subtle);
55
+ border-radius: 6px;
56
+ padding: 8px 10px;
57
+ overflow-wrap: anywhere;
58
+ }
59
+ .card-thumb {
60
+ display: none;
61
+ max-width: 100%;
62
+ border-radius: 8px;
63
+ border: 1px solid var(--run-color-border-default);
64
+ }
65
+ .card-thumb.is-shown { display: block; }
66
+ .card-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 4px; }
67
+ .card-change-row { display: none; align-items: center; gap: 8px; }
68
+ .card-change-row.is-shown { display: flex; }
69
+ .card-status { font-size: 12px; color: var(--run-color-text-secondary); min-height: 1.2em; }
70
+ #placeholder { font-size: 13px; color: var(--run-color-text-secondary); padding: 8px 2px; }
71
+ `;
72
+ const CARD_BODY = `
73
+ <div id="placeholder">Waiting for the region you circled…</div>
74
+ <article class="marker-card" id="card">
75
+ <run-text variant="label" as="span" size="sm" color="secondary">You circled this</run-text>
76
+ <run-text id="card-type" variant="title" as="h3" size="md"></run-text>
77
+ <run-text id="card-note" variant="body" as="p" size="sm" color="secondary"></run-text>
78
+ <pre class="card-inputs" id="card-inputs"></pre>
79
+ <img class="card-thumb" id="card-thumb" alt="The region you circled" />
80
+ <div class="card-actions">
81
+ <run-button id="spotlight-btn" variant="primary" size="sm">Show me where this is</run-button>
82
+ <run-button id="whatis-btn" variant="secondary" size="sm">What is this?</run-button>
83
+ <run-button id="pixels-btn" variant="secondary" size="sm">Show the pixels</run-button>
84
+ </div>
85
+ <div class="card-change-row" id="change-row">
86
+ <select id="variant-select">
87
+ <option value="primary">primary</option>
88
+ <option value="secondary">secondary</option>
89
+ <option value="outline">outline</option>
90
+ <option value="ghost">ghost</option>
91
+ <option value="danger">danger</option>
92
+ <option value="link">link</option>
93
+ </select>
94
+ <run-button id="change-btn" variant="outline" size="sm">Change it</run-button>
95
+ </div>
96
+ <div class="card-status" id="card-status"></div>
97
+ </article>
98
+ `;
99
+ // Static, data-free by design — see gathering-card.ts for the rationale this
100
+ // mirrors: everything user-authored arrives at runtime via the tool-result
101
+ // notification and is assigned with textContent, never interpolated here.
102
+ const CARD_APP_SCRIPT = `
103
+ const { App } = globalThis.__MCP_APP_SDK__;
104
+
105
+ let currentCapture = null;
106
+
107
+ function setText(id, value) {
108
+ document.getElementById(id).textContent = value == null ? '' : String(value);
109
+ }
110
+
111
+ function render(payload) {
112
+ const capture = payload && payload.capture;
113
+ if (!capture) return;
114
+ currentCapture = capture;
115
+ const card = document.getElementById('card');
116
+ const address = capture.address || {};
117
+
118
+ setText('card-type', address.type || 'Unknown component');
119
+ setText('card-note', capture.note || '');
120
+ document.getElementById('card-inputs').textContent =
121
+ JSON.stringify(capture.inputs || {}, null, 2);
122
+
123
+ // "Change it" is JUNE-512's write surface — run-button only. Omit the
124
+ // control entirely for other types rather than shipping a button that 422s.
125
+ const changeRow = document.getElementById('change-row');
126
+ changeRow.classList.toggle('is-shown', address.type === 'run-button');
127
+
128
+ document.getElementById('pixels-btn').disabled = !capture.has_crop;
129
+
130
+ document.getElementById('placeholder').style.display = 'none';
131
+ card.classList.add('is-ready');
132
+ }
133
+
134
+ const app = new App({ name: 'runsnative-marker-card', version: '1.0.0' }, {});
135
+
136
+ // Register before connect() so no notification is missed.
137
+ app.ontoolresult = (params) => {
138
+ if (params && params.isError) {
139
+ setText('placeholder', 'Could not load the marker capture — see the conversation for details.');
140
+ return;
141
+ }
142
+ render(params && params.structuredContent);
143
+ };
144
+
145
+ document.getElementById('spotlight-btn').addEventListener('click', async () => {
146
+ const status = document.getElementById('card-status');
147
+ const address = currentCapture && currentCapture.address;
148
+ const instanceId = address && address.instanceId;
149
+ if (!instanceId) {
150
+ status.textContent = 'This capture has no addressable instance.';
151
+ return;
152
+ }
153
+ status.textContent = 'Pointing your tab at the element you circled…';
154
+ try {
155
+ const result = await app.callServerTool({
156
+ name: 'spotlight',
157
+ arguments: { target: 'instance:' + instanceId, message: "Here's the element you circled." },
158
+ });
159
+ status.textContent = result && result.isError
160
+ ? 'No linked tab — open runsnative.org to see it live.'
161
+ : 'Watch your linked tab — the ring is landing on the element.';
162
+ } catch (e) {
163
+ status.textContent = 'No linked tab — open runsnative.org to see it live.';
164
+ }
165
+ });
166
+
167
+ document.getElementById('whatis-btn').addEventListener('click', async () => {
168
+ const status = document.getElementById('card-status');
169
+ const address = currentCapture && currentCapture.address;
170
+ if (!address || !address.type) {
171
+ status.textContent = 'No component type on this capture.';
172
+ return;
173
+ }
174
+ status.textContent = 'Asking about ' + address.type + '…';
175
+ try {
176
+ const result = await app.callServerTool({ name: 'get_component', arguments: { name: address.type } });
177
+ status.textContent = result && result.isError
178
+ ? 'Could not look up that component.'
179
+ : 'See the conversation for ' + address.type + \"'s contract.\";
180
+ } catch (e) {
181
+ status.textContent = 'Could not look up that component.';
182
+ }
183
+ });
184
+
185
+ document.getElementById('pixels-btn').addEventListener('click', async () => {
186
+ const status = document.getElementById('card-status');
187
+ if (!currentCapture || !currentCapture.has_crop) {
188
+ status.textContent = 'No pixels were captured for this region.';
189
+ return;
190
+ }
191
+ status.textContent = 'Fetching the pixels…';
192
+ try {
193
+ const result = await app.callServerTool({
194
+ name: 'fetch_marker_crop',
195
+ arguments: { capture_id: currentCapture.capture_id },
196
+ });
197
+ const block = result && Array.isArray(result.content)
198
+ ? result.content.find((c) => c.type === 'image')
199
+ : null;
200
+ if (result && result.isError || !block) {
201
+ status.textContent = 'Could not fetch the pixels — they may have expired.';
202
+ return;
203
+ }
204
+ const thumb = document.getElementById('card-thumb');
205
+ thumb.src = 'data:' + (block.mimeType || 'image/png') + ';base64,' + block.data;
206
+ thumb.classList.add('is-shown');
207
+ status.textContent = '';
208
+ } catch (e) {
209
+ status.textContent = 'Could not fetch the pixels — they may have expired.';
210
+ }
211
+ });
212
+
213
+ document.getElementById('change-btn').addEventListener('click', async () => {
214
+ const status = document.getElementById('card-status');
215
+ const address = currentCapture && currentCapture.address;
216
+ const instanceId = address && address.instanceId;
217
+ if (!instanceId) {
218
+ status.textContent = 'This capture has no addressable instance.';
219
+ return;
220
+ }
221
+ const variant = document.getElementById('variant-select').value;
222
+ status.textContent = 'Sending ' + variant + ' to your tab…';
223
+ try {
224
+ const result = await app.callServerTool({
225
+ name: 'set_instance_variant',
226
+ arguments: { instance_id: instanceId, variant: variant },
227
+ });
228
+ status.textContent = result && result.isError
229
+ ? 'No linked tab — open runsnative.org to try it live.'
230
+ : 'Watch your linked tab — the button is switching to ' + variant + '.';
231
+ } catch (e) {
232
+ status.textContent = 'No linked tab — open runsnative.org to try it live.';
233
+ }
234
+ });
235
+
236
+ await app.connect();
237
+ `;
238
+ export function buildMarkerCardHtml(assets) {
239
+ return packageSurface({
240
+ title: 'The region you circled',
241
+ bodyHtml: CARD_BODY,
242
+ css: CARD_CSS,
243
+ appScript: CARD_APP_SCRIPT,
244
+ }, assets);
245
+ }
@@ -0,0 +1,67 @@
1
+ import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
2
+ import { getWorkerApi, getLinkedPairId } from '../bridge-client.js';
3
+ export const FETCH_MARKER_CROP_TOOL = {
4
+ name: 'fetch_marker_crop',
5
+ description: 'Fetch the pixel crop for a marker capture, on demand (JUNE-623 push→pull inversion). ' +
6
+ 'render_marker_capture and get_marker_capture deliver the structural head only — call this when the ' +
7
+ 'user\'s complaint is visual (a gap, alignment, or render fidelity issue that a component-instance ' +
8
+ 'address alone cannot answer). Requires an active linked session; the crop must still be within its ' +
9
+ 'capture window (~15 minutes) or this returns not-found.',
10
+ inputSchema: {
11
+ type: 'object',
12
+ properties: {
13
+ capture_id: {
14
+ type: 'string',
15
+ description: 'The capture_id from a marker-capture head (get_marker_capture or render_marker_capture).',
16
+ },
17
+ },
18
+ required: ['capture_id'],
19
+ },
20
+ // Copresence Protocol §5.1 (copresence-protocol-v0.1, mukadra repo) — a
21
+ // session-targeting READ leg like get_marker_capture: it drains a stored
22
+ // resource rather than enacting a command, so no allow-list `enactment`.
23
+ _meta: {
24
+ copresence: {
25
+ target: 'session',
26
+ tier: 'reversible',
27
+ consent: 'implicit',
28
+ entitlement: 'free',
29
+ visibility: 'advertised',
30
+ },
31
+ },
32
+ };
33
+ export async function handleFetchMarkerCrop(args) {
34
+ const captureId = args['capture_id'];
35
+ if (typeof captureId !== 'string' || !captureId.trim()) {
36
+ throw new McpError(ErrorCode.InvalidParams, 'capture_id must be a non-empty string');
37
+ }
38
+ const pairId = getLinkedPairId();
39
+ if (!pairId) {
40
+ throw new McpError(ErrorCode.InvalidParams, 'No linked session. Call link_session with the pairing code first, or open runsnative.org and start the pairing flow.');
41
+ }
42
+ const res = await getWorkerApi(`/session/${pairId}/captures/${encodeURIComponent(captureId.trim())}/crop`);
43
+ if (res.status === 404) {
44
+ throw new McpError(ErrorCode.InvalidParams, 'Crop not found — it may have expired, or the linked session was re-linked. Re-circle the region if you still need it.');
45
+ }
46
+ if (res.status === 410) {
47
+ throw new McpError(ErrorCode.InvalidParams, 'Linked session revoked or expired. Re-link with link_session.');
48
+ }
49
+ if (res.status === 409) {
50
+ throw new McpError(ErrorCode.InvalidParams, 'No live tab detected. Open runsnative.org, ensure the tab is active, then retry.');
51
+ }
52
+ if (!res.ok) {
53
+ const body = await res.text().catch(() => '');
54
+ throw new McpError(ErrorCode.InternalError, `Worker error ${res.status}: ${body}`);
55
+ }
56
+ const buffer = await res.arrayBuffer();
57
+ const bytes = new Uint8Array(buffer);
58
+ let binary = '';
59
+ const CHUNK = 0x8000;
60
+ for (let i = 0; i < bytes.length; i += CHUNK) {
61
+ binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
62
+ }
63
+ const b64 = btoa(binary);
64
+ return {
65
+ content: [{ type: 'image', data: b64, mimeType: 'image/png' }],
66
+ };
67
+ }
@@ -1,14 +1,15 @@
1
1
  import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
2
2
  import { getWorkerApi, getLinkedPairId } from '../bridge-client.js';
3
- const CROP_PREFIX = 'data:image/png;base64,';
4
3
  export const GET_MARKER_CAPTURE_TOOL = {
5
4
  name: 'get_marker_capture',
6
5
  description: 'Fetch pending marker captures from the linked runsnative.org tab — regions the user circled ' +
7
6
  'with the marker overlay. Use when the user says they marked, circled, or pointed at something ' +
8
7
  'on the site, or asks "can you see what I selected?". Each capture carries the resolved ' +
9
- 'component-instance address (usable with set_instance_variant), its render inputs, an optional ' +
10
- 'user note, and the pixel crop as an image so you see exactly what they saw. Single delivery: a ' +
11
- 'capture is returned once. Requires an active linked session (call link_session first).',
8
+ 'component-instance address (usable with set_instance_variant), its render inputs, and an ' +
9
+ 'optional user note the structural head only (JUNE-623 push→pull inversion). Pixels are NOT ' +
10
+ 'inlined: when `has_crop` is true and the complaint is visual (not structural), call ' +
11
+ 'fetch_marker_crop with the capture_id to see the region. Single delivery: a capture head is ' +
12
+ 'returned once. Requires an active linked session (call link_session first).',
12
13
  inputSchema: {
13
14
  type: 'object',
14
15
  properties: {},
@@ -68,18 +69,9 @@ export async function handleGetMarkerCapture(_args) {
68
69
  inputs: cap.inputs,
69
70
  note: cap.note,
70
71
  created_at: cap.created_at,
72
+ has_crop: cap.has_crop,
71
73
  }, null, 2),
72
74
  });
73
- // The pixel crop is co-equal with the structural address (design doc
74
- // §3.4) — deliver it as an image block so the agent SEES the region,
75
- // not a reference to it.
76
- if (cap.crop && cap.crop.startsWith(CROP_PREFIX)) {
77
- content.push({
78
- type: 'image',
79
- data: cap.crop.slice(CROP_PREFIX.length),
80
- mimeType: 'image/png',
81
- });
82
- }
83
75
  }
84
76
  if (data.truncated) {
85
77
  content.push({
@@ -0,0 +1,83 @@
1
+ import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
2
+ import { getWorkerApi, getLinkedPairId } from '../bridge-client.js';
3
+ import { componentsBundle, iframeRuntime } from '../surface/assets.js';
4
+ import { buildMarkerCardHtml, MARKER_CARD_URI } from '../surface/marker-card.js';
5
+ import { SURFACE_MIME_TYPE } from '../surface/package-surface.js';
6
+ export const RENDER_MARKER_CAPTURE_TOOL = {
7
+ name: 'render_marker_capture',
8
+ description: "Render the user's most recent marker capture — a region they circled with the marker overlay on " +
9
+ 'the linked runsnative.org tab — as a live MCP App card in this conversation (JUNE-623). The card is ' +
10
+ 'a HANDLE, not a picture: it carries live actions targeting the captured instance ("Show me where " +' +
11
+ '"this is", "Change it", "What is this?"), plus an on-demand pixel fetch. Use when the user says they ' +
12
+ 'circled or marked something and you want to hand them (and yourself) a live surface instead of raw ' +
13
+ 'JSON. Requires an active linked session (call link_session first).',
14
+ inputSchema: {
15
+ type: 'object',
16
+ properties: {},
17
+ required: [],
18
+ },
19
+ _meta: {
20
+ ui: { resourceUri: MARKER_CARD_URI },
21
+ // Legacy key for hosts predating _meta.ui (ext-apps compatibility shim).
22
+ 'ui/resourceUri': MARKER_CARD_URI,
23
+ },
24
+ };
25
+ export const MARKER_CARD_RESOURCE = {
26
+ uri: MARKER_CARD_URI,
27
+ name: 'Marker Capture Card',
28
+ description: 'Self-contained MCP App shell that renders a marker capture as a live RunsNative card with ' +
29
+ 'spotlight/change/introspect actions. The pixel crop is fetched on demand, never inlined.',
30
+ mimeType: SURFACE_MIME_TYPE,
31
+ };
32
+ export async function readMarkerCardResource() {
33
+ const html = buildMarkerCardHtml({
34
+ componentsBundle: componentsBundle(),
35
+ iframeRuntime: iframeRuntime(),
36
+ });
37
+ return {
38
+ contents: [{ uri: MARKER_CARD_URI, mimeType: SURFACE_MIME_TYPE, text: html }],
39
+ };
40
+ }
41
+ export async function handleRenderMarkerCapture() {
42
+ const pairId = getLinkedPairId();
43
+ if (!pairId) {
44
+ throw new McpError(ErrorCode.InvalidParams, 'No linked session. Call link_session with the pairing code first, or open runsnative.org and start the pairing flow.');
45
+ }
46
+ const res = await getWorkerApi(`/session/${pairId}/captures`);
47
+ if (res.status === 404) {
48
+ throw new McpError(ErrorCode.InvalidParams, 'Linked session not found or expired. Re-link with link_session.');
49
+ }
50
+ if (res.status === 410) {
51
+ throw new McpError(ErrorCode.InvalidParams, 'Linked session revoked or expired. Re-link with link_session.');
52
+ }
53
+ if (res.status === 409) {
54
+ throw new McpError(ErrorCode.InvalidParams, 'No live tab detected. Open runsnative.org, ensure the tab is active, then retry.');
55
+ }
56
+ if (!res.ok) {
57
+ const body = await res.text().catch(() => '');
58
+ throw new McpError(ErrorCode.InternalError, `Worker error ${res.status}: ${body}`);
59
+ }
60
+ const data = (await res.json());
61
+ const captures = data.captures ?? [];
62
+ if (captures.length === 0) {
63
+ return {
64
+ content: [
65
+ {
66
+ type: 'text',
67
+ text: 'No pending marker captures. Ask the user to arm the marker overlay and circle a region on the linked tab.',
68
+ },
69
+ ],
70
+ };
71
+ }
72
+ // Head endpoint returns oldest-first — the most recent pending capture is last.
73
+ const capture = captures[captures.length - 1];
74
+ return {
75
+ content: [
76
+ {
77
+ type: 'text',
78
+ text: 'Rendered the region the user circled as a live card in the conversation.',
79
+ },
80
+ ],
81
+ structuredContent: { capture },
82
+ };
83
+ }
@@ -0,0 +1,215 @@
1
+ /**
2
+ * render_surface / surface_event tools (JUNE-793) — the MCP-host leg of the
3
+ * agent-surface runtime. Together they close the engine's render → suspend →
4
+ * resume loop at the user:
5
+ *
6
+ * - `render_surface { route }` — the RENDER leg. Calls the engine gateway's
7
+ * `surfaces/query` (threading the linked-session `agent_session_id`),
8
+ * retains the pairing keys, and returns the `SurfaceSpec` for the agent-
9
+ * surface MCP-App shell to mount.
10
+ * - `surface_event { event_type, payload }` — the RESUME leg. Posts the
11
+ * retained keys + the user's typed payload to `surfaces/{name}/event`,
12
+ * handling the terminal ack, the chained successor, and the governor
13
+ * rejection ("surface no longer active") distinctly.
14
+ *
15
+ * The active surface is held module-level for the MCP server process lifetime
16
+ * (one surface in flight at a time), mirroring bridge-client's `_linkedPairId`.
17
+ * The transport is injectable so tests exercise the full loop with a stub —
18
+ * no engine required.
19
+ */
20
+ import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
21
+ import { getLinkedPairId } from '../bridge-client.js';
22
+ import { componentsBundle, iframeRuntime } from '../surface/assets.js';
23
+ import { AGENT_SURFACE_URI, buildAgentSurfaceHtml } from '../surface/agent-surface.js';
24
+ import { SURFACE_MIME_TYPE } from '../surface/package-surface.js';
25
+ import { engineGatewayTransport } from '../surface/engine-gateway-transport.js';
26
+ let _surfaceHostClientModule;
27
+ /** Lazy, memoized load of the surface-host-client runtime classes. */
28
+ async function loadSurfaceHostClient() {
29
+ if (!_surfaceHostClientModule) {
30
+ _surfaceHostClientModule = await import('../surface/surface-host-client.js');
31
+ }
32
+ return _surfaceHostClientModule;
33
+ }
34
+ // ---------------------------------------------------------------------------
35
+ // Module-level state — one active surface per MCP server process, and an
36
+ // injectable transport (default: the engine gateway over HTTP).
37
+ // ---------------------------------------------------------------------------
38
+ let _transport = engineGatewayTransport;
39
+ let _session = null;
40
+ /** Test seam — swap the engine transport for a stub. */
41
+ export function _setSurfaceTransport(t) {
42
+ _transport = t;
43
+ }
44
+ /** Test seam — reset transport + active surface between tests. */
45
+ export function _resetSurfaceHost() {
46
+ _transport = engineGatewayTransport;
47
+ _session = null;
48
+ }
49
+ /** The session identity threaded on both legs: the linked pair, or an env override. */
50
+ function currentAgentSessionId() {
51
+ const linked = getLinkedPairId();
52
+ if (linked)
53
+ return linked;
54
+ const env = process.env['MUKADRA_AGENT_SESSION_ID']?.trim();
55
+ return env ? env : undefined;
56
+ }
57
+ // ---------------------------------------------------------------------------
58
+ // Tool + resource definitions
59
+ // ---------------------------------------------------------------------------
60
+ export const RENDER_SURFACE_TOOL = {
61
+ name: 'render_surface',
62
+ description: 'Render an agent surface at a route: ask the Mukadra engine what component to render there, ' +
63
+ 'mount it as a live MCP App in this conversation, and suspend for the user to interact. The ' +
64
+ "user's typed response is posted back with surface_event, and the agent continues. Use when an " +
65
+ 'agent reasoning step needs a user pick that a component surface can capture (the render → ' +
66
+ 'suspend → resume loop). Requires a linked session so the resume is governed (resumable); ' +
67
+ 'without one the surface renders non-resumable (one-shot).',
68
+ inputSchema: {
69
+ type: 'object',
70
+ properties: {
71
+ route: {
72
+ type: 'string',
73
+ description: "The surface route to resolve (e.g. '/pick/customer'). Matched by the engine's surface catalog.",
74
+ },
75
+ },
76
+ required: ['route'],
77
+ },
78
+ _meta: {
79
+ ui: { resourceUri: AGENT_SURFACE_URI },
80
+ 'ui/resourceUri': AGENT_SURFACE_URI,
81
+ },
82
+ };
83
+ export const SURFACE_EVENT_TOOL = {
84
+ name: 'surface_event',
85
+ description: 'Post the user\'s response to the active surface back to the engine (the resume leg). Called by ' +
86
+ 'the surface shell when the user interacts; resolves the suspended agent step. Returns the ' +
87
+ 'outcome: done (agent continues), chained (a successor surface to render), or expired (the ' +
88
+ 'surface is no longer active).',
89
+ inputSchema: {
90
+ type: 'object',
91
+ properties: {
92
+ event_type: { type: 'string', description: "Interaction type, e.g. 'submit', 'click', 'cancel'. Defaults to 'submit'." },
93
+ payload: { description: "The user's typed response, shaped by the surface's response contract." },
94
+ },
95
+ required: [],
96
+ },
97
+ };
98
+ export const AGENT_SURFACE_RESOURCE = {
99
+ uri: AGENT_SURFACE_URI,
100
+ name: 'Agent Surface',
101
+ description: 'Self-contained MCP App shell that mounts whatever component the engine surfaces at a route and ' +
102
+ 'posts the user response back. Spec arrives via the tool-result notification; nothing external is loaded.',
103
+ mimeType: SURFACE_MIME_TYPE,
104
+ };
105
+ export async function readAgentSurfaceResource() {
106
+ const html = buildAgentSurfaceHtml({
107
+ componentsBundle: componentsBundle(),
108
+ iframeRuntime: iframeRuntime(),
109
+ });
110
+ return { contents: [{ uri: AGENT_SURFACE_URI, mimeType: SURFACE_MIME_TYPE, text: html }] };
111
+ }
112
+ // ---------------------------------------------------------------------------
113
+ // Handlers
114
+ // ---------------------------------------------------------------------------
115
+ export async function handleRenderSurface(args) {
116
+ const route = typeof args['route'] === 'string' ? args['route'].trim() : '';
117
+ if (!route) {
118
+ throw new McpError(ErrorCode.InvalidParams, 'render_surface requires a non-empty "route".');
119
+ }
120
+ const { SurfaceHostSession } = await loadSurfaceHostClient();
121
+ const session = new SurfaceHostSession({
122
+ transport: _transport,
123
+ agentSessionId: currentAgentSessionId(),
124
+ });
125
+ const outcome = await session.render(route);
126
+ if (outcome.status === 'not_found') {
127
+ // No declared surface matches this route (render-leg 404). Distinct from a
128
+ // governor rejection, which can only happen on the resume leg.
129
+ throw new McpError(ErrorCode.InvalidParams, `No surface is declared at route "${route}". Check the route, or the agent's surface catalog.`);
130
+ }
131
+ // Retain the active surface for the subsequent surface_event call.
132
+ _session = session;
133
+ const resumableNote = outcome.resumable
134
+ ? 'The surface is governed (resumable) — interact, and surface_event resumes the agent.'
135
+ : 'No linked session — this surface is NON-RESUMABLE (one-shot); link a session first to enable resume.';
136
+ return {
137
+ content: [
138
+ {
139
+ type: 'text',
140
+ text: `Rendered surface "${outcome.surfaceName}" (${outcome.spec.component}) at route "${route}". ` +
141
+ resumableNote,
142
+ },
143
+ ],
144
+ structuredContent: {
145
+ spec: outcome.spec,
146
+ surface_instance_id: outcome.surfaceInstanceId,
147
+ correlation_id: outcome.correlationId,
148
+ resumable: outcome.resumable,
149
+ },
150
+ };
151
+ }
152
+ export async function handleSurfaceEvent(args) {
153
+ if (!_session || !_session.hasActiveSurface) {
154
+ throw new McpError(ErrorCode.InvalidParams, 'No active surface to respond to. Render one first with render_surface.');
155
+ }
156
+ const eventType = typeof args['event_type'] === 'string' && args['event_type'].trim() ? args['event_type'].trim() : 'submit';
157
+ const payload = args['payload'];
158
+ let outcome;
159
+ try {
160
+ outcome = await _session.submit(eventType, payload);
161
+ }
162
+ catch (e) {
163
+ // Already loaded — reaching here requires an active _session, which only
164
+ // handleRenderSurface (which loads the module first) can have set.
165
+ const { SurfaceHostStateError } = await loadSurfaceHostClient();
166
+ if (e instanceof SurfaceHostStateError) {
167
+ // Non-resumable (session-less) surface, or ordering violation — a clean
168
+ // host-side signal, not a crash.
169
+ _session = null;
170
+ throw new McpError(ErrorCode.InvalidParams, e.message);
171
+ }
172
+ throw e;
173
+ }
174
+ switch (outcome.status) {
175
+ case 'done':
176
+ _session = null;
177
+ return {
178
+ content: [{ type: 'text', text: 'Response accepted — the agent is continuing.' }],
179
+ structuredContent: { status: 'done', correlation_id: outcome.correlationId },
180
+ };
181
+ case 'chained':
182
+ // Successor surface parked by the engine (same correlation_id). The shell
183
+ // re-mounts spec and loops.
184
+ return {
185
+ content: [
186
+ { type: 'text', text: `Rendered the next surface (${outcome.spec.component}). Waiting for the user's response.` },
187
+ ],
188
+ structuredContent: {
189
+ status: 'chained',
190
+ spec: outcome.spec,
191
+ surface_instance_id: outcome.surfaceInstanceId,
192
+ correlation_id: outcome.correlationId,
193
+ },
194
+ };
195
+ case 'expired':
196
+ // Governor rejection — expired / cross-session / already-resolved. This
197
+ // is the "surface no longer active" state, distinct from a 404.
198
+ _session = null;
199
+ return {
200
+ content: [
201
+ {
202
+ type: 'text',
203
+ text: 'That surface is no longer active (it expired or was already resolved). Render it again to continue.',
204
+ },
205
+ ],
206
+ structuredContent: { status: 'expired' },
207
+ };
208
+ case 'not_found':
209
+ _session = null;
210
+ return {
211
+ content: [{ type: 'text', text: 'That surface is no longer available (unknown surface or route).' }],
212
+ structuredContent: { status: 'not_found' },
213
+ };
214
+ }
215
+ }
@@ -1,7 +1,7 @@
1
1
  import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
2
2
  export const SEARCH_DOCS_TOOL = {
3
3
  name: 'search_docs',
4
- description: 'Lexical search across all RunsNative component documentation, foundations, and exercises. ' +
4
+ description: 'Lexical search across all RunsNative component documentation, foundations, exercises, and composition patterns (recipes). ' +
5
5
  'Useful when you do not know the exact component name — for example, searching "focus ring token" ' +
6
6
  'or "which component handles multi-select". Returns scored results with excerpts.',
7
7
  inputSchema: {