@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 +45 -1
- package/dist/content.js +58 -17
- package/dist/server.js +44 -11
- package/dist/surface/agent-surface.js +183 -0
- package/dist/surface/engine-gateway-transport.js +82 -0
- package/dist/surface/gathering-card.js +1 -1
- package/dist/surface/marker-card.js +245 -0
- package/dist/tools/fetch-marker-crop.js +67 -0
- package/dist/tools/get-marker-capture.js +6 -14
- package/dist/tools/render-marker-capture.js +83 -0
- package/dist/tools/render-surface.js +215 -0
- package/dist/tools/search-docs.js +1 -1
- package/dist/tools/session-tools.js +38 -0
- package/dist/tools/set-contrast.js +59 -0
- package/dist/tools/set-mode.js +59 -0
- package/dist/tools/set-site-content.js +69 -0
- package/dist/tools/set-skin.js +60 -0
- package/package.json +1 -1
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
|
|
7
|
-
//
|
|
8
|
-
|
|
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(
|
|
11
|
-
throw new Error(`Content store not found at ${
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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['
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
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
|
-
|
|
65
|
-
|
|
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="
|
|
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>
|