@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.
package/README.md CHANGED
@@ -75,9 +75,53 @@ Paste that URL into Claude's "Add custom skill" dialog, or add it to your agent'
75
75
  | `link_session` | Pair the agent with a logged-in runsnative.org tab (consent-gated) |
76
76
  | `navigate` / `set_theme` | Drive the linked tab: change routes, switch themes |
77
77
  | `set_instance_variant` | Change a component instance in the linked tab by marker-capture handle |
78
- | `get_marker_capture` | Receive regions circled with the marker overlay |
78
+ | `get_marker_capture` | Receive regions circled with the marker overlay — structural head only, pixels not inlined |
79
+ | `render_marker_capture` | Render the most recent marker capture as a live MCP App card with spotlight/change/introspect actions (JUNE-623) |
80
+ | `fetch_marker_crop` | Fetch a marker capture's pixel crop on demand — the lazy-pixel pull |
79
81
  | `render_gathering` | Render the user's latest Convene gathering as a live, branded MCP App card in the conversation (JUNE-618) |
80
82
 
83
+ ## Marker capture: push→pull pixel economics (JUNE-623)
84
+
85
+ `get_marker_capture` and `render_marker_capture` deliver a capture's **structural head** by
86
+ default — the resolved component-instance address, render inputs, and note — and never inline
87
+ the pixel crop. The agent fetches pixels only when the complaint is visual, via
88
+ `fetch_marker_crop`. The rationale (JUNE-623, founder-normative): the user already sees the real
89
+ thing on their screen — the screenshot-paste ritual was always for the agent's benefit, so let
90
+ the consumer of the information decide its own input-token budget.
91
+
92
+ The three-way comparison below is computed, not eyeballed — reproduce it with the formulas
93
+ below rather than trusting the numbers as given.
94
+
95
+ | Path | What's sent | Tokens |
96
+ |---|---|---|
97
+ | (a) Copy-paste screenshot | Full-viewport image, no structural data | **~1,366** |
98
+ | (b) Lasso, crop fetched | Structural head (JSON) **+** the fetched crop image | **~150** |
99
+ | (c) Lasso, metadata-only | Structural head (JSON) only — `fetch_marker_crop` never called | **~98** |
100
+
101
+ Methodology:
102
+ - **Image tokens** use Claude's documented vision approximation, `tokens ≈ (width_px × height_px) / 750`.
103
+ - (a) assumes a full-viewport screenshot at a common desktop capture size, 1280×800 → `(1280×800)/750 ≈ 1366`.
104
+ - (b) assumes a tight marker-overlay crop around the circled element, 320×120 → `(320×120)/750 ≈ 52`.
105
+ - **Text tokens** use the standard ~4-characters-per-token approximation, applied to the actual
106
+ JSON `get_marker_capture` / `render_marker_capture` emit for a capture head:
107
+ ```json
108
+ {
109
+ "capture_id": "8f3a2b1c-4d5e-4a6b-9c7d-1e2f3a4b5c6d",
110
+ "address": { "type": "run-button", "path": "main>section>run-button", "index": 2, "instanceId": "a1b2c3d4-e5f6-4789-abcd-0123456789ab" },
111
+ "inputs": { "data-run-skin": "default", "data-run-mode": "light" },
112
+ "note": "make this one pop",
113
+ "created_at": "2026-07-13T14:22:00.000Z",
114
+ "has_crop": true
115
+ }
116
+ ```
117
+ 391 characters → `391/4 ≈ 98` tokens.
118
+ - (b) = 98 (head) + 52 (crop) ≈ 150. (c) = 98 (head only).
119
+
120
+ Both lasso paths beat the copy-paste screenshot on tokens; metadata-only is the largest win
121
+ (~93% fewer tokens than the screenshot) and is exact — the agent already has the machine-readable
122
+ address, whereas OCR-from-pixels for a structural change ("make this secondary") is a lossy
123
+ detour on top of the token cost.
124
+
81
125
  ## Environment variables
82
126
 
83
127
  | Variable | Default | Description |
package/dist/content.js CHANGED
@@ -3,12 +3,19 @@ import { existsSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  const VALID_TABS = ['usage', 'style', 'code', 'accessibility'];
6
- // Content store root same directory the dotorg-site reads from.
7
- // fileURLToPath handles Windows drive letters correctly (avoids /C:/C:/ doubling).
8
- const CONTENT_ROOT = fileURLToPath(new URL('../../../content', import.meta.url));
6
+ // Content store roots. fileURLToPath handles Windows drive letters correctly
7
+ // (avoids /C:/C:/ doubling).
8
+ //
9
+ // JUNE-656 moved the .org site's content store (components, foundations,
10
+ // recipes) into domains/runsnative-org/state/data/content. The shared kernel
11
+ // (exercises, changelog) stays at repo-root content/ because sibling package
12
+ // release/deploy pipelines consume it too. Mirrors the same split in
13
+ // packages/mcp-api-worker/scripts/upload-content-to-kv.ts.
14
+ const DOMAIN_CONTENT_ROOT = fileURLToPath(new URL('../../../domains/runsnative-org/state/data/content', import.meta.url));
15
+ const SHARED_CONTENT_ROOT = fileURLToPath(new URL('../../../content', import.meta.url));
9
16
  export function validateContentRoot() {
10
- if (!existsSync(CONTENT_ROOT)) {
11
- throw new Error(`Content store not found at ${CONTENT_ROOT}. ` +
17
+ if (!existsSync(DOMAIN_CONTENT_ROOT)) {
18
+ throw new Error(`Content store not found at ${DOMAIN_CONTENT_ROOT}. ` +
12
19
  `Run from the runsnative repo root or set RUNSNATIVE_CONTENT_ROOT.`);
13
20
  }
14
21
  }
@@ -19,7 +26,7 @@ export async function getComponent(name, tab) {
19
26
  if (!isValidTab(tab)) {
20
27
  throw new RangeError(`Unknown tab "${tab}". Valid tabs: ${VALID_TABS.join(', ')}.`);
21
28
  }
22
- const filePath = join(CONTENT_ROOT, 'components', name, `${tab}.md`);
29
+ const filePath = join(DOMAIN_CONTENT_ROOT, 'components', name, `${tab}.md`);
23
30
  if (!existsSync(filePath)) {
24
31
  throw new RangeError(`Component "${name}" has no "${tab}" tab. ` +
25
32
  `Check content/components/${name}/${tab}.md exists and status is "ready".`);
@@ -47,7 +54,7 @@ export function parseFrontmatter(raw) {
47
54
  return result;
48
55
  }
49
56
  export async function listComponentMeta(includeDrafts = false) {
50
- const componentsDir = join(CONTENT_ROOT, 'components');
57
+ const componentsDir = join(DOMAIN_CONTENT_ROOT, 'components');
51
58
  if (!existsSync(componentsDir))
52
59
  return [];
53
60
  const entries = await readdir(componentsDir, { withFileTypes: true });
@@ -75,7 +82,7 @@ export async function listComponentMeta(includeDrafts = false) {
75
82
  return results.sort((a, b) => a.name.localeCompare(b.name));
76
83
  }
77
84
  export async function getFoundation(name) {
78
- const filePath = join(CONTENT_ROOT, 'foundations', `${name}.md`);
85
+ const filePath = join(DOMAIN_CONTENT_ROOT, 'foundations', `${name}.md`);
79
86
  if (!existsSync(filePath)) {
80
87
  throw new RangeError(`Foundation "${name}" not found. ` +
81
88
  `Check content/foundations/${name}.md exists.`);
@@ -83,7 +90,7 @@ export async function getFoundation(name) {
83
90
  return readFile(filePath, 'utf8');
84
91
  }
85
92
  export async function listFoundations() {
86
- const foundationsDir = join(CONTENT_ROOT, 'foundations');
93
+ const foundationsDir = join(DOMAIN_CONTENT_ROOT, 'foundations');
87
94
  if (!existsSync(foundationsDir))
88
95
  return [];
89
96
  const entries = await readdir(foundationsDir);
@@ -139,7 +146,7 @@ export async function searchDocs(query, limit = 5) {
139
146
  const queryTokens = tokenize(query);
140
147
  const candidates = [];
141
148
  // Index component tabs
142
- const componentsDir = join(CONTENT_ROOT, 'components');
149
+ const componentsDir = join(DOMAIN_CONTENT_ROOT, 'components');
143
150
  if (existsSync(componentsDir)) {
144
151
  const componentDirs = await readdir(componentsDir, { withFileTypes: true });
145
152
  for (const dir of componentDirs) {
@@ -164,7 +171,7 @@ export async function searchDocs(query, limit = 5) {
164
171
  }
165
172
  }
166
173
  // Index foundations
167
- const foundationsDir = join(CONTENT_ROOT, 'foundations');
174
+ const foundationsDir = join(DOMAIN_CONTENT_ROOT, 'foundations');
168
175
  if (existsSync(foundationsDir)) {
169
176
  const entries = await readdir(foundationsDir);
170
177
  for (const entry of entries) {
@@ -183,12 +190,46 @@ export async function searchDocs(query, limit = 5) {
183
190
  }
184
191
  }
185
192
  }
193
+ // Index exercises (content/exercises/<name>/index.md + steps/*.md)
194
+ const exercisesDir = join(SHARED_CONTENT_ROOT, 'exercises');
195
+ if (existsSync(exercisesDir)) {
196
+ const exDirs = await readdir(exercisesDir, { withFileTypes: true });
197
+ for (const dir of exDirs) {
198
+ if (!dir.isDirectory())
199
+ continue;
200
+ const indexPath = join(exercisesDir, dir.name, 'index.md');
201
+ if (!existsSync(indexPath))
202
+ continue;
203
+ let text = await readFile(indexPath, 'utf8');
204
+ const stepsDir = join(exercisesDir, dir.name, 'steps');
205
+ if (existsSync(stepsDir)) {
206
+ for (const step of (await readdir(stepsDir)).filter(f => f.endsWith('.md')).sort()) {
207
+ text += '\n' + await readFile(join(stepsDir, step), 'utf8');
208
+ }
209
+ }
210
+ const score = scoreDoc(text, queryTokens);
211
+ if (score > 0) {
212
+ candidates.push({ type: 'exercise', name: dir.name, excerpt: excerpt(text, query), score });
213
+ }
214
+ }
215
+ }
216
+ // Index recipes / composition patterns (content/recipes/*.md)
217
+ const recipesDir = join(DOMAIN_CONTENT_ROOT, 'recipes');
218
+ if (existsSync(recipesDir)) {
219
+ for (const entry of (await readdir(recipesDir)).filter(f => f.endsWith('.md') && f !== '_index.md')) {
220
+ const text = await readFile(join(recipesDir, entry), 'utf8');
221
+ const score = scoreDoc(text, queryTokens);
222
+ if (score > 0) {
223
+ candidates.push({ type: 'recipe', name: entry.replace(/\.md$/, ''), excerpt: excerpt(text, query), score });
224
+ }
225
+ }
226
+ }
186
227
  return candidates
187
228
  .sort((a, b) => b.score - a.score)
188
229
  .slice(0, limit);
189
230
  }
190
231
  export async function listRecipeMeta() {
191
- const recipesDir = join(CONTENT_ROOT, 'recipes');
232
+ const recipesDir = join(DOMAIN_CONTENT_ROOT, 'recipes');
192
233
  if (!existsSync(recipesDir))
193
234
  return [];
194
235
  const entries = await readdir(recipesDir);
@@ -205,14 +246,14 @@ export async function listRecipeMeta() {
205
246
  results.push({
206
247
  name,
207
248
  title: fm['title'] ?? name,
208
- group: fm['recipe-group'] ?? '',
249
+ group: fm['group'] ?? '',
209
250
  status: fm['status'] ?? 'unknown',
210
251
  });
211
252
  }
212
253
  return results.sort((a, b) => a.name.localeCompare(b.name));
213
254
  }
214
255
  export async function getRecipe(name) {
215
- const filePath = join(CONTENT_ROOT, 'recipes', `${name}.md`);
256
+ const filePath = join(DOMAIN_CONTENT_ROOT, 'recipes', `${name}.md`);
216
257
  if (!existsSync(filePath)) {
217
258
  const available = (await listRecipeMeta()).map(r => r.name).join(', ');
218
259
  throw new RangeError(`Recipe "${name}" not found. Available patterns: ${available || 'none'}.`);
@@ -239,7 +280,7 @@ function extractSection(text, heading) {
239
280
  return '';
240
281
  }
241
282
  export async function listExerciseMeta(includeDrafts = false) {
242
- const exercisesDir = join(CONTENT_ROOT, 'exercises');
283
+ const exercisesDir = join(SHARED_CONTENT_ROOT, 'exercises');
243
284
  if (!existsSync(exercisesDir))
244
285
  return [];
245
286
  const entries = await readdir(exercisesDir, { withFileTypes: true });
@@ -268,7 +309,7 @@ export async function listExerciseMeta(includeDrafts = false) {
268
309
  return results.sort((a, b) => a.name.localeCompare(b.name));
269
310
  }
270
311
  export async function getExerciseDetail(name) {
271
- const exercisesDir = join(CONTENT_ROOT, 'exercises');
312
+ const exercisesDir = join(SHARED_CONTENT_ROOT, 'exercises');
272
313
  const indexPath = join(exercisesDir, name, 'index.md');
273
314
  if (!existsSync(indexPath)) {
274
315
  throw new RangeError(`Exercise "${name}" not found. Check content/exercises/${name}/index.md exists.`);
@@ -299,7 +340,7 @@ export async function getExerciseDetail(name) {
299
340
  };
300
341
  }
301
342
  export async function getExerciseStep(name, step) {
302
- const exercisesDir = join(CONTENT_ROOT, 'exercises');
343
+ const exercisesDir = join(SHARED_CONTENT_ROOT, 'exercises');
303
344
  const stepsDir = join(exercisesDir, name, 'steps');
304
345
  if (!existsSync(stepsDir)) {
305
346
  throw new RangeError(`Exercise "${name}" not found.`);
package/dist/server.js CHANGED
@@ -17,12 +17,24 @@ import { GET_COMPOSITION_PATTERN_TOOL, handleGetCompositionPattern } from './too
17
17
  import { GET_EMPHASIS_SCALE_TOOL, handleGetEmphasisScale } from './tools/get-emphasis-scale.js';
18
18
  import { GET_COMPLETENESS_MAP_TOOL, handleGetCompletenessMap } from './tools/get-completeness-map.js';
19
19
  import { LINK_SESSION_TOOL, handleLinkSession } from './tools/link-session.js';
20
- import { SET_THEME_TOOL, handleSetTheme } from './tools/set-theme.js';
21
- import { APPLY_INFERRED_THEME_TOOL, handleApplyInferredTheme } from './tools/apply-inferred-theme.js';
22
- import { NAVIGATE_TOOL, handleNavigate } from './tools/navigate.js';
23
- import { SET_INSTANCE_VARIANT_TOOL, handleSetInstanceVariant } from './tools/set-instance-variant.js';
24
- import { SPOTLIGHT_TOOL, handleSpotlight } from './tools/spotlight.js';
20
+ import { SET_SITE_CONTENT_TOOL, handleSetSiteContent } from './tools/set-site-content.js';
21
+ import { handleSetTheme } from './tools/set-theme.js';
22
+ import { handleSetSkin } from './tools/set-skin.js';
23
+ import { handleSetMode } from './tools/set-mode.js';
24
+ import { handleSetContrast } from './tools/set-contrast.js';
25
+ import { handleApplyInferredTheme } from './tools/apply-inferred-theme.js';
26
+ import { handleNavigate } from './tools/navigate.js';
27
+ import { handleSetInstanceVariant } from './tools/set-instance-variant.js';
28
+ import { handleSpotlight } from './tools/spotlight.js';
29
+ // Session-enact tools register via the single-source list (JUNE-739) so the
30
+ // registration surface and the reconciliation tests read the same array.
31
+ import { SESSION_ENACT_TOOLS } from './tools/session-tools.js';
25
32
  import { GET_MARKER_CAPTURE_TOOL, handleGetMarkerCapture } from './tools/get-marker-capture.js';
33
+ import { RENDER_MARKER_CAPTURE_TOOL, MARKER_CARD_RESOURCE, handleRenderMarkerCapture, readMarkerCardResource, } from './tools/render-marker-capture.js';
34
+ import { MARKER_CARD_URI } from './surface/marker-card.js';
35
+ import { FETCH_MARKER_CROP_TOOL, handleFetchMarkerCrop } from './tools/fetch-marker-crop.js';
36
+ import { RENDER_SURFACE_TOOL, SURFACE_EVENT_TOOL, AGENT_SURFACE_RESOURCE, handleRenderSurface, handleSurfaceEvent, readAgentSurfaceResource, } from './tools/render-surface.js';
37
+ import { AGENT_SURFACE_URI } from './surface/agent-surface.js';
26
38
  import { createProvider } from './provider.js';
27
39
  import { RemoteContentProvider } from './remote-provider.js';
28
40
  /**
@@ -61,12 +73,13 @@ export async function createRunsnativeServer() {
61
73
  GET_EMPHASIS_SCALE_TOOL,
62
74
  GET_COMPLETENESS_MAP_TOOL,
63
75
  LINK_SESSION_TOOL,
64
- SET_THEME_TOOL,
65
- APPLY_INFERRED_THEME_TOOL,
66
- NAVIGATE_TOOL,
67
- SET_INSTANCE_VARIANT_TOOL,
68
- SPOTLIGHT_TOOL,
76
+ SET_SITE_CONTENT_TOOL,
77
+ ...SESSION_ENACT_TOOLS,
69
78
  GET_MARKER_CAPTURE_TOOL,
79
+ RENDER_MARKER_CAPTURE_TOOL,
80
+ FETCH_MARKER_CROP_TOOL,
81
+ RENDER_SURFACE_TOOL,
82
+ SURFACE_EVENT_TOOL,
70
83
  ],
71
84
  }));
72
85
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
@@ -101,8 +114,16 @@ export async function createRunsnativeServer() {
101
114
  return handleGetCompletenessMap();
102
115
  case 'link_session':
103
116
  return handleLinkSession(request.params.arguments ?? {});
117
+ case 'set_site_content':
118
+ return handleSetSiteContent(request.params.arguments ?? {});
104
119
  case 'set_theme':
105
120
  return handleSetTheme(request.params.arguments ?? {});
121
+ case 'set_skin':
122
+ return handleSetSkin(request.params.arguments ?? {});
123
+ case 'set_mode':
124
+ return handleSetMode(request.params.arguments ?? {});
125
+ case 'set_contrast':
126
+ return handleSetContrast(request.params.arguments ?? {});
106
127
  case 'apply_inferred_theme':
107
128
  return handleApplyInferredTheme(request.params.arguments ?? {});
108
129
  case 'navigate':
@@ -113,6 +134,14 @@ export async function createRunsnativeServer() {
113
134
  return handleSpotlight(request.params.arguments ?? {});
114
135
  case 'get_marker_capture':
115
136
  return handleGetMarkerCapture(request.params.arguments ?? {});
137
+ case 'render_marker_capture':
138
+ return handleRenderMarkerCapture();
139
+ case 'fetch_marker_crop':
140
+ return handleFetchMarkerCrop(request.params.arguments ?? {});
141
+ case 'render_surface':
142
+ return handleRenderSurface(request.params.arguments ?? {});
143
+ case 'surface_event':
144
+ return handleSurfaceEvent(request.params.arguments ?? {});
116
145
  default:
117
146
  throw new Error(`Unknown tool: ${request.params.name}`);
118
147
  }
@@ -121,12 +150,16 @@ export async function createRunsnativeServer() {
121
150
  // host reads the resource and renders it in a sandboxed iframe; each document
122
151
  // is fully self-contained (bundle + runtime + CSS inlined by the seam).
123
152
  server.setRequestHandler(ListResourcesRequestSchema, async () => ({
124
- resources: [GATHERING_CARD_RESOURCE],
153
+ resources: [GATHERING_CARD_RESOURCE, MARKER_CARD_RESOURCE, AGENT_SURFACE_RESOURCE],
125
154
  }));
126
155
  server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
127
156
  switch (request.params.uri) {
128
157
  case GATHERING_CARD_URI:
129
158
  return readGatheringCardResource();
159
+ case MARKER_CARD_URI:
160
+ return readMarkerCardResource();
161
+ case AGENT_SURFACE_URI:
162
+ return readAgentSurfaceResource();
130
163
  default:
131
164
  throw new Error(`Unknown resource: ${request.params.uri}`);
132
165
  }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * The agent-surface MCP-App shell (JUNE-793) — the generic host-side render
3
+ * target for the Mukadra engine's suspend/resume Surface protocol.
4
+ *
5
+ * Unlike the hand-authored gathering-card / marker-card shells (which each
6
+ * render ONE known component), this shell mounts whatever `SurfaceSpec` the
7
+ * engine returns (`component` ref + `props`) and closes the render → interact →
8
+ * resume loop for ANY surface. That is the mechanism this ticket proves —
9
+ * authoring a specific surface example is deliberately out of scope (JUNE-793).
10
+ *
11
+ * Static shell, data-free by design (same seam invariant as gathering-card):
12
+ * the per-instance spec arrives at runtime via the MCP Apps tool-result
13
+ * notification and is rendered client-side with DOM APIs; nothing user- or
14
+ * engine-authored is interpolated into markup here.
15
+ *
16
+ * Host ↔ component resume-leg convention: a surface component signals the
17
+ * user's typed response by dispatching a `surface:submit` CustomEvent whose
18
+ * `detail` is the payload. The shell forwards it to the engine via the
19
+ * `surface_event` server tool. A fallback submit control is always present so
20
+ * the loop is walkable even before a component emits that event.
21
+ */
22
+ import { packageSurface } from './package-surface.js';
23
+ export const AGENT_SURFACE_URI = 'ui://runsnative/agent-surface.html';
24
+ const SURFACE_CSS = `
25
+ /* Shell defaults in the lowest layer so injected token-engine CSS always wins. */
26
+ @layer rn-shell, primitives, semantics, themes;
27
+ @layer rn-shell {
28
+ :root {
29
+ --run-color-text-primary: #1e293b;
30
+ --run-color-text-secondary: #64748b;
31
+ --run-color-surface-default: #ffffff;
32
+ --run-color-border-default: #e2e8f0;
33
+ --run-color-focus-ring: #2563eb;
34
+ }
35
+ }
36
+ * { box-sizing: border-box; margin: 0; padding: 0; }
37
+ body {
38
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
39
+ color: var(--run-color-text-primary);
40
+ padding: 12px;
41
+ }
42
+ #placeholder { font-size: 13px; color: var(--run-color-text-secondary); padding: 8px 2px; }
43
+ .surface-frame {
44
+ max-width: 480px;
45
+ border: 1px solid var(--run-color-border-default);
46
+ border-radius: 12px;
47
+ background: var(--run-color-surface-default);
48
+ padding: 16px;
49
+ display: none;
50
+ flex-direction: column;
51
+ gap: 12px;
52
+ }
53
+ .surface-frame.is-ready { display: flex; }
54
+ .surface-ref { font-size: 11px; color: var(--run-color-text-secondary); letter-spacing: 0.02em; }
55
+ .surface-mount { display: flex; flex-direction: column; gap: 8px; }
56
+ .surface-fallback { display: flex; gap: 8px; align-items: center; }
57
+ .surface-fallback input {
58
+ flex: 1; padding: 6px 8px; font: inherit;
59
+ border: 1px solid var(--run-color-border-default); border-radius: 6px;
60
+ }
61
+ .surface-status { font-size: 12px; color: var(--run-color-text-secondary); min-height: 1.2em; }
62
+ .surface-status.is-expired { color: #b45309; }
63
+ `;
64
+ const SURFACE_BODY = `
65
+ <div id="placeholder">Waiting for a surface…</div>
66
+ <section class="surface-frame" id="frame" aria-live="polite">
67
+ <div class="surface-ref" id="surface-ref"></div>
68
+ <div class="surface-mount" id="mount"></div>
69
+ <div class="surface-fallback">
70
+ <input id="fallback-input" type="text" placeholder="Your response…" aria-label="Surface response" />
71
+ <run-button id="submit-btn" variant="primary" size="sm">Submit</run-button>
72
+ </div>
73
+ <div class="surface-status" id="status"></div>
74
+ </section>
75
+ `;
76
+ // Data-free by design: the spec and its props arrive via the tool-result
77
+ // notification and are applied with DOM APIs only. A surface component signals
78
+ // its response by dispatching `surface:submit` (detail = payload); the shell
79
+ // also carries a plain fallback control so the loop is always walkable.
80
+ const SURFACE_APP_SCRIPT = `
81
+ const { App } = globalThis.__MCP_APP_SDK__;
82
+
83
+ // ComponentRef '<ns>:<name>@<ver>' → the '<name>' tag, sanitized to the safe
84
+ // custom-element charset so a hostile spec can never break out of the tag.
85
+ function tagFromRef(ref) {
86
+ const name = String(ref == null ? '' : ref).split(':').pop().split('@')[0];
87
+ const safe = name.replace(/[^a-z0-9-]/gi, '').toLowerCase();
88
+ return safe && safe.includes('-') ? safe : null;
89
+ }
90
+
91
+ let mounted = null;
92
+
93
+ function mountSpec(spec) {
94
+ const frame = document.getElementById('frame');
95
+ const mount = document.getElementById('mount');
96
+ mount.textContent = '';
97
+ document.getElementById('surface-ref').textContent = spec && spec.component ? String(spec.component) : '';
98
+
99
+ const tag = spec && tagFromRef(spec.component);
100
+ if (tag) {
101
+ const el = document.createElement(tag);
102
+ const props = spec && spec.props && typeof spec.props === 'object' ? spec.props : {};
103
+ for (const [k, v] of Object.entries(props)) {
104
+ if (v == null) continue;
105
+ // Primitive props ride as attributes; richer props as element properties.
106
+ if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') {
107
+ el.setAttribute(k, String(v));
108
+ } else {
109
+ try { el[k] = v; } catch (_) { /* read-only prop — skip */ }
110
+ }
111
+ }
112
+ // The component's own submit gesture: dispatch surface:submit(detail=payload).
113
+ el.addEventListener('surface:submit', (e) => submit(e && e.detail));
114
+ mount.appendChild(el);
115
+ mounted = el;
116
+ } else {
117
+ // Unknown/undeclared component — render the ref so the surface is never
118
+ // blank (the real component arrives when a specific surface is authored).
119
+ const note = document.createElement('div');
120
+ note.className = 'surface-ref';
121
+ note.textContent = 'Surface component not available in this host bundle.';
122
+ mount.appendChild(note);
123
+ }
124
+
125
+ document.getElementById('placeholder').style.display = 'none';
126
+ document.getElementById('status').className = 'surface-status';
127
+ document.getElementById('status').textContent = '';
128
+ frame.classList.add('is-ready');
129
+ }
130
+
131
+ async function submit(payload) {
132
+ const status = document.getElementById('status');
133
+ status.className = 'surface-status';
134
+ status.textContent = 'Sending your response…';
135
+ try {
136
+ const result = await app.callServerTool({
137
+ name: 'surface_event',
138
+ arguments: { event_type: 'submit', payload: payload },
139
+ });
140
+ const sc = result && result.structuredContent;
141
+ const kind = sc && sc.status;
142
+ if (kind === 'chained' && sc.spec) {
143
+ mountSpec(sc.spec); // successor surface — render and loop.
144
+ } else if (kind === 'expired') {
145
+ status.className = 'surface-status is-expired';
146
+ status.textContent = 'This surface is no longer active. Ask the agent to render it again.';
147
+ } else if (kind === 'not_found') {
148
+ status.textContent = 'That surface is no longer available.';
149
+ } else {
150
+ status.textContent = 'Response sent — the agent is continuing.';
151
+ }
152
+ } catch (e) {
153
+ status.textContent = 'Could not send your response — see the conversation.';
154
+ }
155
+ }
156
+
157
+ const app = new App({ name: 'runsnative-agent-surface', version: '1.0.0' }, {});
158
+
159
+ app.ontoolresult = (params) => {
160
+ if (params && params.isError) {
161
+ document.getElementById('placeholder').textContent =
162
+ 'Could not load the surface — see the conversation for details.';
163
+ return;
164
+ }
165
+ const sc = params && params.structuredContent;
166
+ if (sc && sc.spec) mountSpec(sc.spec);
167
+ };
168
+
169
+ document.getElementById('submit-btn').addEventListener('click', () => {
170
+ const input = document.getElementById('fallback-input');
171
+ submit({ value: input.value });
172
+ });
173
+
174
+ await app.connect();
175
+ `;
176
+ export function buildAgentSurfaceHtml(assets) {
177
+ return packageSurface({
178
+ title: 'Agent Surface — RunsNative',
179
+ bodyHtml: SURFACE_BODY,
180
+ css: SURFACE_CSS,
181
+ appScript: SURFACE_APP_SCRIPT,
182
+ }, assets);
183
+ }
@@ -0,0 +1,82 @@
1
+ // trim(): trailing whitespace from setx/cmd wrappers silently corrupts URLs and
2
+ // the Authorization header (KUKAMANGA-wide env-read rule; see bridge-client.ts).
3
+ const ENGINE_URL = (process.env['MUKADRA_ENGINE_URL'] ?? 'http://127.0.0.1:8100').trim();
4
+ const ENGINE_TOKEN = process.env['MUKADRA_ENGINE_TOKEN']?.trim();
5
+ /** The agent whose gateway hosts the surfaces (forms `{id}` in the path). */
6
+ const AGENT_ID = (process.env['MUKADRA_AGENT_ID'] ?? 'runsnative-org').trim();
7
+ function authHeaders(extra = {}) {
8
+ const headers = { ...extra };
9
+ if (ENGINE_TOKEN)
10
+ headers['Authorization'] = `Bearer ${ENGINE_TOKEN}`;
11
+ return headers;
12
+ }
13
+ /**
14
+ * Read a gateway short-circuit error out of a non-OK response body. The engine
15
+ * returns typed errors (`{ kind, stage, ... }`); the `stage` discriminant is
16
+ * what the host uses to separate a governor rejection from a dispatch 404. When
17
+ * the body is not the expected shape, fall back to a dispatch-stage error so an
18
+ * unparseable failure is never mistaken for an expired-surface state.
19
+ */
20
+ async function readGatewayError(res, fallbackRoute) {
21
+ let body = null;
22
+ try {
23
+ body = await res.json();
24
+ }
25
+ catch {
26
+ body = null;
27
+ }
28
+ const b = (body ?? {});
29
+ const stage = typeof b['stage'] === 'string' ? b['stage'] : 'dispatch';
30
+ const kind = typeof b['kind'] === 'string' ? b['kind'] : 'surface_not_found';
31
+ return {
32
+ kind,
33
+ stage,
34
+ route: typeof b['route'] === 'string' ? b['route'] : fallbackRoute,
35
+ correlation_id: typeof b['correlation_id'] === 'string' ? b['correlation_id'] : undefined,
36
+ message: typeof b['message'] === 'string' ? b['message'] : `engine responded ${res.status}`,
37
+ };
38
+ }
39
+ /** The default transport, targeting the Mukadra engine gateway over HTTP. */
40
+ export const engineGatewayTransport = {
41
+ async queryRoute(route, ctx) {
42
+ const url = `${ENGINE_URL}/agents/${encodeURIComponent(AGENT_ID)}/surfaces/query?route=${encodeURIComponent(route)}`;
43
+ const headers = authHeaders();
44
+ // Session identity as a header, never a URL param (privacy rule).
45
+ if (ctx.agentSessionId)
46
+ headers['X-Agent-Session-Id'] = ctx.agentSessionId;
47
+ if (ctx.correlationId)
48
+ headers['X-Correlation-ID'] = ctx.correlationId;
49
+ let res;
50
+ try {
51
+ res = await fetch(url, { method: 'GET', headers });
52
+ }
53
+ catch (e) {
54
+ return { ok: false, error: { kind: 'transport_error', stage: 'dispatch', route, message: String(e) } };
55
+ }
56
+ if (!res.ok) {
57
+ return { ok: false, error: await readGatewayError(res, route) };
58
+ }
59
+ const value = (await res.json());
60
+ return { ok: true, value };
61
+ },
62
+ async postEvent(surfaceName, req) {
63
+ const url = `${ENGINE_URL}/agents/${encodeURIComponent(AGENT_ID)}/surfaces/${encodeURIComponent(surfaceName)}/event`;
64
+ const headers = authHeaders({ 'Content-Type': 'application/json' });
65
+ if (req.correlation_id)
66
+ headers['X-Correlation-ID'] = req.correlation_id;
67
+ let res;
68
+ try {
69
+ res = await fetch(url, { method: 'POST', headers, body: JSON.stringify(req) });
70
+ }
71
+ catch (e) {
72
+ return { ok: false, error: { kind: 'transport_error', stage: 'dispatch', message: String(e) } };
73
+ }
74
+ if (!res.ok) {
75
+ return { ok: false, error: await readGatewayError(res) };
76
+ }
77
+ const value = (await res.json());
78
+ return { ok: true, value };
79
+ },
80
+ };
81
+ /** The agent id the transport targets — exported for host-side messaging/tests. */
82
+ export const ENGINE_AGENT_ID = AGENT_ID;
@@ -60,7 +60,7 @@ const CARD_BODY = `
60
60
  <div id="placeholder">Waiting for your gathering…</div>
61
61
  <article class="gathering-card" id="card">
62
62
  <div class="card-host-bar">
63
- <run-text id="card-brand" variant="label" as="span" size="sm" color="theme"></run-text>
63
+ <run-text id="card-brand" variant="label" as="span" size="sm" color="brand"></run-text>
64
64
  <run-badge variant="info" size="sm" pill>Yours</run-badge>
65
65
  </div>
66
66
  <run-text id="card-title" variant="title" as="h3" size="md"></run-text>