@runsnative/mcp-server 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/dist/components-inline.js +15478 -0
- package/dist/dev-host.js +68 -0
- package/dist/iframe-runtime.js +103 -0
- package/dist/index.js +2 -103
- package/dist/inferrer/nav-error.js +31 -0
- package/dist/inferrer/page-fetcher.js +6 -26
- package/dist/server.js +131 -0
- package/dist/surface/assets.js +89 -0
- package/dist/surface/gathering-card.js +162 -0
- package/dist/surface/iframe-runtime-entry.js +8 -0
- package/dist/surface/package-surface.js +59 -0
- package/dist/tokens/tokens-arctic-frost.css +1320 -0
- package/dist/tokens/tokens-botanical-garden.css +1320 -0
- package/dist/tokens/tokens-bridgesai.css +1269 -0
- package/dist/tokens/tokens-clay-studio.css +1320 -0
- package/dist/tokens/tokens-daisy-acid.css +1320 -0
- package/dist/tokens/tokens-daisy-aqua.css +1320 -0
- package/dist/tokens/tokens-daisy-autumn.css +1320 -0
- package/dist/tokens/tokens-daisy-black.css +1321 -0
- package/dist/tokens/tokens-daisy-bumblebee.css +1320 -0
- package/dist/tokens/tokens-daisy-business.css +1320 -0
- package/dist/tokens/tokens-daisy-cmyk.css +1321 -0
- package/dist/tokens/tokens-daisy-coffee.css +1320 -0
- package/dist/tokens/tokens-daisy-corporate.css +1320 -0
- package/dist/tokens/tokens-daisy-cupcake.css +1320 -0
- package/dist/tokens/tokens-daisy-cyberpunk.css +1320 -0
- package/dist/tokens/tokens-daisy-dark.css +1320 -0
- package/dist/tokens/tokens-daisy-dim.css +1320 -0
- package/dist/tokens/tokens-daisy-dracula.css +1320 -0
- package/dist/tokens/tokens-daisy-emerald.css +1320 -0
- package/dist/tokens/tokens-daisy-fantasy.css +1320 -0
- package/dist/tokens/tokens-daisy-forest.css +1320 -0
- package/dist/tokens/tokens-daisy-garden.css +1320 -0
- package/dist/tokens/tokens-daisy-halloween.css +1320 -0
- package/dist/tokens/tokens-daisy-lemonade.css +1320 -0
- package/dist/tokens/tokens-daisy-light.css +1320 -0
- package/dist/tokens/tokens-daisy-lofi.css +1321 -0
- package/dist/tokens/tokens-daisy-luxury.css +1320 -0
- package/dist/tokens/tokens-daisy-night.css +1320 -0
- package/dist/tokens/tokens-daisy-nord.css +1320 -0
- package/dist/tokens/tokens-daisy-pastel.css +1320 -0
- package/dist/tokens/tokens-daisy-retro.css +1320 -0
- package/dist/tokens/tokens-daisy-sunset.css +1320 -0
- package/dist/tokens/tokens-daisy-synthwave.css +1320 -0
- package/dist/tokens/tokens-daisy-valentine.css +1320 -0
- package/dist/tokens/tokens-daisy-winter.css +1320 -0
- package/dist/tokens/tokens-daisy-wireframe.css +1321 -0
- package/dist/tokens/tokens-daisyui-base.css +1269 -0
- package/dist/tokens/tokens-desert-rose.css +1320 -0
- package/dist/tokens/tokens-dotorg-site.css +1269 -0
- package/dist/tokens/tokens-forest-canopy.css +1320 -0
- package/dist/tokens/tokens-golden-hour.css +1320 -0
- package/dist/tokens/tokens-harlow-pets.css +1320 -0
- package/dist/tokens/tokens-kehribar.css +1320 -0
- package/dist/tokens/tokens-midnight-galaxy.css +1320 -0
- package/dist/tokens/tokens-modern-minimalist.css +1270 -0
- package/dist/tokens/tokens-ocean-depths.css +1320 -0
- package/dist/tokens/tokens-showcase-base.css +1269 -0
- package/dist/tokens/tokens-smoke-bold.css +1269 -0
- package/dist/tokens/tokens-smoke-friendly.css +1269 -0
- package/dist/tokens/tokens-smoke-minimal.css +1281 -0
- package/dist/tokens/tokens-smoke-premium.css +1280 -0
- package/dist/tokens/tokens-smoke-professional.css +1269 -0
- package/dist/tokens/tokens-smoke-retail.css +1321 -0
- package/dist/tokens/tokens-smoke-startup.css +1269 -0
- package/dist/tokens/tokens-solvlys.css +1320 -0
- package/dist/tokens/tokens-sunset-boulevard.css +1320 -0
- package/dist/tokens/tokens-tech-innovation.css +1320 -0
- package/dist/tokens/tokens-theme-atrium.css +1320 -0
- package/dist/tokens/tokens-theme-catalyst.css +1320 -0
- package/dist/tokens/tokens-theme-circuit.css +1320 -0
- package/dist/tokens/tokens-theme-dispatch.css +1320 -0
- package/dist/tokens/tokens-theme-dither.css +1320 -0
- package/dist/tokens/tokens-theme-ember.css +1320 -0
- package/dist/tokens/tokens-theme-fanfare.css +1320 -0
- package/dist/tokens/tokens-theme-ferro.css +1321 -0
- package/dist/tokens/tokens-theme-folio.css +1320 -0
- package/dist/tokens/tokens-theme-hamara.css +1270 -0
- package/dist/tokens/tokens-theme-komorebi.css +1321 -0
- package/dist/tokens/tokens-theme-kyo.css +1269 -0
- package/dist/tokens/tokens-theme-lattice.css +1320 -0
- package/dist/tokens/tokens-theme-ma.css +1321 -0
- package/dist/tokens/tokens-theme-marquee.css +1320 -0
- package/dist/tokens/tokens-theme-meridian.css +1320 -0
- package/dist/tokens/tokens-theme-nimbus.css +1320 -0
- package/dist/tokens/tokens-theme-plasma.css +1320 -0
- package/dist/tokens/tokens-theme-plinth.css +1320 -0
- package/dist/tokens/tokens-theme-quartz.css +1320 -0
- package/dist/tokens/tokens-theme-sus.css +1320 -0
- package/dist/tokens/tokens-theme-trellis.css +1269 -0
- package/dist/tokens/tokens-theme-vesper.css +1320 -0
- package/dist/tokens/tokens-theme-wabi.css +1320 -0
- package/dist/tools/apply-inferred-theme.js +89 -0
- package/dist/tools/render-gathering.js +88 -0
- package/package.json +5 -2
- package/dist/tools/surface-preview.js +0 -36
package/dist/index.js
CHANGED
|
@@ -1,107 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { createRequire } from 'node:module';
|
|
3
|
-
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
4
2
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
import { LIST_COMPONENTS_TOOL, handleListComponents } from './tools/list-components.js';
|
|
8
|
-
import { GET_FOUNDATION_TOOL, handleGetFoundation } from './tools/get-foundation.js';
|
|
9
|
-
import { SEARCH_DOCS_TOOL, handleSearchDocs } from './tools/search-docs.js';
|
|
10
|
-
import { INFER_THEME_TOOL, handleInferTheme } from './tools/infer-theme.js';
|
|
11
|
-
import { INFER_BRAND_THEME_TOOL, handleInferBrandTheme } from './tools/infer-brand-theme.js';
|
|
12
|
-
import { LIST_EXERCISES_TOOL, handleListExercises } from './tools/list-exercises.js';
|
|
13
|
-
import { START_EXERCISE_TOOL, handleStartExercise } from './tools/start-exercise.js';
|
|
14
|
-
import { GET_STEP_TOOL, handleGetStep } from './tools/get-step.js';
|
|
15
|
-
import { SURFACE_PREVIEW_TOOL, handleSurfacePreview } from './tools/surface-preview.js';
|
|
16
|
-
import { LIST_COMPOSITION_PATTERNS_TOOL, handleListCompositionPatterns } from './tools/list-composition-patterns.js';
|
|
17
|
-
import { GET_COMPOSITION_PATTERN_TOOL, handleGetCompositionPattern } from './tools/get-composition-pattern.js';
|
|
18
|
-
import { GET_EMPHASIS_SCALE_TOOL, handleGetEmphasisScale } from './tools/get-emphasis-scale.js';
|
|
19
|
-
import { GET_COMPLETENESS_MAP_TOOL, handleGetCompletenessMap } from './tools/get-completeness-map.js';
|
|
20
|
-
import { LINK_SESSION_TOOL, handleLinkSession } from './tools/link-session.js';
|
|
21
|
-
import { SET_THEME_TOOL, handleSetTheme } from './tools/set-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 { GET_MARKER_CAPTURE_TOOL, handleGetMarkerCapture } from './tools/get-marker-capture.js';
|
|
25
|
-
import { createProvider } from './provider.js';
|
|
26
|
-
import { RemoteContentProvider } from './remote-provider.js';
|
|
27
|
-
// Create the content provider — LocalContentProvider if content tree is present,
|
|
28
|
-
// RemoteContentProvider otherwise. Initialize runs the manifest version check.
|
|
29
|
-
const provider = await createProvider();
|
|
30
|
-
if (provider instanceof RemoteContentProvider) {
|
|
31
|
-
await provider.initialize();
|
|
32
|
-
}
|
|
33
|
-
// Advertised version reads package.json at runtime — a literal here drifts
|
|
34
|
-
// the moment the next release forgets to update it (0.3.0 survived four
|
|
35
|
-
// releases; 0.1.4 outlived its release within hours).
|
|
36
|
-
const require = createRequire(import.meta.url);
|
|
37
|
-
const { version: pkgVersion } = require('../package.json');
|
|
38
|
-
const server = new Server({ name: 'runsnative-mcp', version: pkgVersion }, { capabilities: { tools: {} } });
|
|
39
|
-
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
40
|
-
tools: [
|
|
41
|
-
GET_COMPONENT_TOOL,
|
|
42
|
-
LIST_COMPONENTS_TOOL,
|
|
43
|
-
GET_FOUNDATION_TOOL,
|
|
44
|
-
SEARCH_DOCS_TOOL,
|
|
45
|
-
INFER_THEME_TOOL,
|
|
46
|
-
INFER_BRAND_THEME_TOOL,
|
|
47
|
-
LIST_EXERCISES_TOOL,
|
|
48
|
-
START_EXERCISE_TOOL,
|
|
49
|
-
GET_STEP_TOOL,
|
|
50
|
-
SURFACE_PREVIEW_TOOL,
|
|
51
|
-
LIST_COMPOSITION_PATTERNS_TOOL,
|
|
52
|
-
GET_COMPOSITION_PATTERN_TOOL,
|
|
53
|
-
GET_EMPHASIS_SCALE_TOOL,
|
|
54
|
-
GET_COMPLETENESS_MAP_TOOL,
|
|
55
|
-
LINK_SESSION_TOOL,
|
|
56
|
-
SET_THEME_TOOL,
|
|
57
|
-
NAVIGATE_TOOL,
|
|
58
|
-
SET_INSTANCE_VARIANT_TOOL,
|
|
59
|
-
GET_MARKER_CAPTURE_TOOL,
|
|
60
|
-
],
|
|
61
|
-
}));
|
|
62
|
-
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
63
|
-
switch (request.params.name) {
|
|
64
|
-
case 'get_component':
|
|
65
|
-
return handleGetComponent(provider, request.params.arguments ?? {});
|
|
66
|
-
case 'list_components':
|
|
67
|
-
return handleListComponents(provider, request.params.arguments ?? {});
|
|
68
|
-
case 'get_foundation':
|
|
69
|
-
return handleGetFoundation(provider, request.params.arguments ?? {});
|
|
70
|
-
case 'search_docs':
|
|
71
|
-
return handleSearchDocs(provider, request.params.arguments ?? {});
|
|
72
|
-
case 'infer_theme':
|
|
73
|
-
return handleInferTheme(request.params.arguments ?? {});
|
|
74
|
-
case 'infer_brand_theme':
|
|
75
|
-
return handleInferBrandTheme(request.params.arguments ?? {});
|
|
76
|
-
case 'list_exercises':
|
|
77
|
-
return handleListExercises(provider, request.params.arguments ?? {});
|
|
78
|
-
case 'start_exercise':
|
|
79
|
-
return handleStartExercise(provider, request.params.arguments ?? {});
|
|
80
|
-
case 'get_step':
|
|
81
|
-
return handleGetStep(provider, request.params.arguments ?? {});
|
|
82
|
-
case 'surface_preview':
|
|
83
|
-
return handleSurfacePreview();
|
|
84
|
-
case 'list_composition_patterns':
|
|
85
|
-
return handleListCompositionPatterns(provider, request.params.arguments ?? {});
|
|
86
|
-
case 'get_composition_pattern':
|
|
87
|
-
return handleGetCompositionPattern(provider, request.params.arguments ?? {});
|
|
88
|
-
case 'get_emphasis_scale':
|
|
89
|
-
return handleGetEmphasisScale();
|
|
90
|
-
case 'get_completeness_map':
|
|
91
|
-
return handleGetCompletenessMap();
|
|
92
|
-
case 'link_session':
|
|
93
|
-
return handleLinkSession(request.params.arguments ?? {});
|
|
94
|
-
case 'set_theme':
|
|
95
|
-
return handleSetTheme(request.params.arguments ?? {});
|
|
96
|
-
case 'navigate':
|
|
97
|
-
return handleNavigate(request.params.arguments ?? {});
|
|
98
|
-
case 'set_instance_variant':
|
|
99
|
-
return handleSetInstanceVariant(request.params.arguments ?? {});
|
|
100
|
-
case 'get_marker_capture':
|
|
101
|
-
return handleGetMarkerCapture(request.params.arguments ?? {});
|
|
102
|
-
default:
|
|
103
|
-
throw new Error(`Unknown tool: ${request.params.name}`);
|
|
104
|
-
}
|
|
105
|
-
});
|
|
3
|
+
import { createRunsnativeServer } from './server.js';
|
|
4
|
+
const server = await createRunsnativeServer();
|
|
106
5
|
const transport = new StdioServerTransport();
|
|
107
6
|
await server.connect(transport);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Navigation-failure classification, extracted from page-fetcher.ts (JUNE-607) so the
|
|
2
|
+
// Cloudflare Worker port can import it without dragging in page-fetcher's lazy
|
|
3
|
+
// `import('playwright')` — esbuild cannot bundle a literal dynamic import of a package
|
|
4
|
+
// that is not installed. This module is pure (no imports, no I/O); page-fetcher.ts
|
|
5
|
+
// re-exports everything here, so existing consumers are unaffected.
|
|
6
|
+
/**
|
|
7
|
+
* Map a Chromium/Playwright navigation error string to a user-meaningful reason. Signatures
|
|
8
|
+
* verified against live failures: ecolab.com → ERR_HTTP2_PROTOCOL_ERROR (bot-block),
|
|
9
|
+
* a dead domain → ERR_NAME_NOT_RESOLVED, badssl.com → ERR_CERT_*, port 1 → ERR_UNSAFE_PORT.
|
|
10
|
+
*/
|
|
11
|
+
export function classifyNavError(cause) {
|
|
12
|
+
const c = cause.toUpperCase();
|
|
13
|
+
if (/\bTIMEOUT\b|EXCEEDED/.test(c))
|
|
14
|
+
return 'timeout';
|
|
15
|
+
if (c.includes('ERR_NAME_NOT_RESOLVED') || c.includes('ERR_NAME_RESOLUTION_FAILED'))
|
|
16
|
+
return 'not_found';
|
|
17
|
+
if (c.includes('ERR_CERT'))
|
|
18
|
+
return 'tls';
|
|
19
|
+
// Connection reset / HTTP2 protocol errors on the initial navigation are the fingerprint of
|
|
20
|
+
// a WAF/bot wall fingerprinting headless Chromium (verified: ecolab.com). ERR_ACCESS_DENIED
|
|
21
|
+
// and ERR_BLOCKED_BY_* are the explicit forms.
|
|
22
|
+
if (c.includes('ERR_HTTP2_PROTOCOL_ERROR') || c.includes('ERR_CONNECTION_RESET') ||
|
|
23
|
+
c.includes('ERR_ACCESS_DENIED') || c.includes('ERR_BLOCKED'))
|
|
24
|
+
return 'blocked';
|
|
25
|
+
if (c.includes('ERR_CONNECTION_REFUSED') || c.includes('ERR_CONNECTION_TIMED_OUT') ||
|
|
26
|
+
c.includes('ERR_ADDRESS_UNREACHABLE') || c.includes('ERR_UNSAFE_PORT') ||
|
|
27
|
+
c.includes('ERR_CONNECTION_CLOSED') || c.includes('ERR_EMPTY_RESPONSE') ||
|
|
28
|
+
c.includes('ERR_SOCKET_NOT_CONNECTED'))
|
|
29
|
+
return 'unreachable';
|
|
30
|
+
return 'unknown';
|
|
31
|
+
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { extractStylesFromPage } from './style-extractor.js';
|
|
2
|
+
import { classifyNavError } from './nav-error.js';
|
|
3
|
+
// Failure classification lives in nav-error.ts (JUNE-607) so the Cloudflare Worker port can
|
|
4
|
+
// share it without importing this module's lazy `import('playwright')` (esbuild cannot bundle
|
|
5
|
+
// a literal dynamic import of an uninstalled package). Re-exported so existing consumers
|
|
6
|
+
// (pipeline, tests, the MCP tool) keep their import path.
|
|
7
|
+
export { classifyNavError };
|
|
2
8
|
export async function loadChromium() {
|
|
3
9
|
try {
|
|
4
10
|
const playwright = await import('playwright');
|
|
@@ -8,32 +14,6 @@ export async function loadChromium() {
|
|
|
8
14
|
return null;
|
|
9
15
|
}
|
|
10
16
|
}
|
|
11
|
-
/**
|
|
12
|
-
* Map a Chromium/Playwright navigation error string to a user-meaningful reason. Signatures
|
|
13
|
-
* verified against live failures: ecolab.com → ERR_HTTP2_PROTOCOL_ERROR (bot-block),
|
|
14
|
-
* a dead domain → ERR_NAME_NOT_RESOLVED, badssl.com → ERR_CERT_*, port 1 → ERR_UNSAFE_PORT.
|
|
15
|
-
*/
|
|
16
|
-
export function classifyNavError(cause) {
|
|
17
|
-
const c = cause.toUpperCase();
|
|
18
|
-
if (/\bTIMEOUT\b|EXCEEDED/.test(c))
|
|
19
|
-
return 'timeout';
|
|
20
|
-
if (c.includes('ERR_NAME_NOT_RESOLVED') || c.includes('ERR_NAME_RESOLUTION_FAILED'))
|
|
21
|
-
return 'not_found';
|
|
22
|
-
if (c.includes('ERR_CERT'))
|
|
23
|
-
return 'tls';
|
|
24
|
-
// Connection reset / HTTP2 protocol errors on the initial navigation are the fingerprint of
|
|
25
|
-
// a WAF/bot wall fingerprinting headless Chromium (verified: ecolab.com). ERR_ACCESS_DENIED
|
|
26
|
-
// and ERR_BLOCKED_BY_* are the explicit forms.
|
|
27
|
-
if (c.includes('ERR_HTTP2_PROTOCOL_ERROR') || c.includes('ERR_CONNECTION_RESET') ||
|
|
28
|
-
c.includes('ERR_ACCESS_DENIED') || c.includes('ERR_BLOCKED'))
|
|
29
|
-
return 'blocked';
|
|
30
|
-
if (c.includes('ERR_CONNECTION_REFUSED') || c.includes('ERR_CONNECTION_TIMED_OUT') ||
|
|
31
|
-
c.includes('ERR_ADDRESS_UNREACHABLE') || c.includes('ERR_UNSAFE_PORT') ||
|
|
32
|
-
c.includes('ERR_CONNECTION_CLOSED') || c.includes('ERR_EMPTY_RESPONSE') ||
|
|
33
|
-
c.includes('ERR_SOCKET_NOT_CONNECTED'))
|
|
34
|
-
return 'unreachable';
|
|
35
|
-
return 'unknown';
|
|
36
|
-
}
|
|
37
17
|
const VIEWPORT = { width: 1280, height: 800 };
|
|
38
18
|
// Navigation timing. `domcontentloaded` is the hard gate; `networkidle` is a bounded bonus.
|
|
39
19
|
const GOTO_TIMEOUT_MS = 25_000; // hard cap on the initial document navigation
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
3
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
4
|
+
import { GET_COMPONENT_TOOL, handleGetComponent } from './tools/get-component.js';
|
|
5
|
+
import { LIST_COMPONENTS_TOOL, handleListComponents } from './tools/list-components.js';
|
|
6
|
+
import { GET_FOUNDATION_TOOL, handleGetFoundation } from './tools/get-foundation.js';
|
|
7
|
+
import { SEARCH_DOCS_TOOL, handleSearchDocs } from './tools/search-docs.js';
|
|
8
|
+
import { INFER_THEME_TOOL, handleInferTheme } from './tools/infer-theme.js';
|
|
9
|
+
import { INFER_BRAND_THEME_TOOL, handleInferBrandTheme } from './tools/infer-brand-theme.js';
|
|
10
|
+
import { LIST_EXERCISES_TOOL, handleListExercises } from './tools/list-exercises.js';
|
|
11
|
+
import { START_EXERCISE_TOOL, handleStartExercise } from './tools/start-exercise.js';
|
|
12
|
+
import { GET_STEP_TOOL, handleGetStep } from './tools/get-step.js';
|
|
13
|
+
import { RENDER_GATHERING_TOOL, GATHERING_CARD_RESOURCE, handleRenderGathering, readGatheringCardResource, } from './tools/render-gathering.js';
|
|
14
|
+
import { GATHERING_CARD_URI } from './surface/gathering-card.js';
|
|
15
|
+
import { LIST_COMPOSITION_PATTERNS_TOOL, handleListCompositionPatterns } from './tools/list-composition-patterns.js';
|
|
16
|
+
import { GET_COMPOSITION_PATTERN_TOOL, handleGetCompositionPattern } from './tools/get-composition-pattern.js';
|
|
17
|
+
import { GET_EMPHASIS_SCALE_TOOL, handleGetEmphasisScale } from './tools/get-emphasis-scale.js';
|
|
18
|
+
import { GET_COMPLETENESS_MAP_TOOL, handleGetCompletenessMap } from './tools/get-completeness-map.js';
|
|
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 { GET_MARKER_CAPTURE_TOOL, handleGetMarkerCapture } from './tools/get-marker-capture.js';
|
|
25
|
+
import { createProvider } from './provider.js';
|
|
26
|
+
import { RemoteContentProvider } from './remote-provider.js';
|
|
27
|
+
/**
|
|
28
|
+
* Build a fully-registered runsnative MCP Server. Transport-agnostic: the
|
|
29
|
+
* stdio entry (index.ts) and the dev/verification HTTP entry (dev-host.ts)
|
|
30
|
+
* both consume this factory, so tool/resource registration cannot drift
|
|
31
|
+
* between transports.
|
|
32
|
+
*/
|
|
33
|
+
export async function createRunsnativeServer() {
|
|
34
|
+
// Content provider — LocalContentProvider if a content tree is present,
|
|
35
|
+
// RemoteContentProvider otherwise. Initialize runs the manifest version check.
|
|
36
|
+
const provider = await createProvider();
|
|
37
|
+
if (provider instanceof RemoteContentProvider) {
|
|
38
|
+
await provider.initialize();
|
|
39
|
+
}
|
|
40
|
+
// Advertised version reads package.json at runtime — a literal here drifts
|
|
41
|
+
// the moment the next release forgets to update it (0.3.0 survived four
|
|
42
|
+
// releases; 0.1.4 outlived its release within hours).
|
|
43
|
+
const require = createRequire(import.meta.url);
|
|
44
|
+
const { version: pkgVersion } = require('../package.json');
|
|
45
|
+
const server = new Server({ name: 'runsnative-mcp', version: pkgVersion }, { capabilities: { tools: {}, resources: {} } });
|
|
46
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
47
|
+
tools: [
|
|
48
|
+
GET_COMPONENT_TOOL,
|
|
49
|
+
LIST_COMPONENTS_TOOL,
|
|
50
|
+
GET_FOUNDATION_TOOL,
|
|
51
|
+
SEARCH_DOCS_TOOL,
|
|
52
|
+
INFER_THEME_TOOL,
|
|
53
|
+
INFER_BRAND_THEME_TOOL,
|
|
54
|
+
LIST_EXERCISES_TOOL,
|
|
55
|
+
START_EXERCISE_TOOL,
|
|
56
|
+
GET_STEP_TOOL,
|
|
57
|
+
RENDER_GATHERING_TOOL,
|
|
58
|
+
LIST_COMPOSITION_PATTERNS_TOOL,
|
|
59
|
+
GET_COMPOSITION_PATTERN_TOOL,
|
|
60
|
+
GET_EMPHASIS_SCALE_TOOL,
|
|
61
|
+
GET_COMPLETENESS_MAP_TOOL,
|
|
62
|
+
LINK_SESSION_TOOL,
|
|
63
|
+
SET_THEME_TOOL,
|
|
64
|
+
APPLY_INFERRED_THEME_TOOL,
|
|
65
|
+
NAVIGATE_TOOL,
|
|
66
|
+
SET_INSTANCE_VARIANT_TOOL,
|
|
67
|
+
GET_MARKER_CAPTURE_TOOL,
|
|
68
|
+
],
|
|
69
|
+
}));
|
|
70
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
71
|
+
switch (request.params.name) {
|
|
72
|
+
case 'get_component':
|
|
73
|
+
return handleGetComponent(provider, request.params.arguments ?? {});
|
|
74
|
+
case 'list_components':
|
|
75
|
+
return handleListComponents(provider, request.params.arguments ?? {});
|
|
76
|
+
case 'get_foundation':
|
|
77
|
+
return handleGetFoundation(provider, request.params.arguments ?? {});
|
|
78
|
+
case 'search_docs':
|
|
79
|
+
return handleSearchDocs(provider, request.params.arguments ?? {});
|
|
80
|
+
case 'infer_theme':
|
|
81
|
+
return handleInferTheme(request.params.arguments ?? {});
|
|
82
|
+
case 'infer_brand_theme':
|
|
83
|
+
return handleInferBrandTheme(request.params.arguments ?? {});
|
|
84
|
+
case 'list_exercises':
|
|
85
|
+
return handleListExercises(provider, request.params.arguments ?? {});
|
|
86
|
+
case 'start_exercise':
|
|
87
|
+
return handleStartExercise(provider, request.params.arguments ?? {});
|
|
88
|
+
case 'get_step':
|
|
89
|
+
return handleGetStep(provider, request.params.arguments ?? {});
|
|
90
|
+
case 'render_gathering':
|
|
91
|
+
return handleRenderGathering();
|
|
92
|
+
case 'list_composition_patterns':
|
|
93
|
+
return handleListCompositionPatterns(provider, request.params.arguments ?? {});
|
|
94
|
+
case 'get_composition_pattern':
|
|
95
|
+
return handleGetCompositionPattern(provider, request.params.arguments ?? {});
|
|
96
|
+
case 'get_emphasis_scale':
|
|
97
|
+
return handleGetEmphasisScale();
|
|
98
|
+
case 'get_completeness_map':
|
|
99
|
+
return handleGetCompletenessMap();
|
|
100
|
+
case 'link_session':
|
|
101
|
+
return handleLinkSession(request.params.arguments ?? {});
|
|
102
|
+
case 'set_theme':
|
|
103
|
+
return handleSetTheme(request.params.arguments ?? {});
|
|
104
|
+
case 'apply_inferred_theme':
|
|
105
|
+
return handleApplyInferredTheme(request.params.arguments ?? {});
|
|
106
|
+
case 'navigate':
|
|
107
|
+
return handleNavigate(request.params.arguments ?? {});
|
|
108
|
+
case 'set_instance_variant':
|
|
109
|
+
return handleSetInstanceVariant(request.params.arguments ?? {});
|
|
110
|
+
case 'get_marker_capture':
|
|
111
|
+
return handleGetMarkerCapture(request.params.arguments ?? {});
|
|
112
|
+
default:
|
|
113
|
+
throw new Error(`Unknown tool: ${request.params.name}`);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
// MCP App surfaces (JUNE-618): ui:// resources referenced by tool _meta. The
|
|
117
|
+
// host reads the resource and renders it in a sandboxed iframe; each document
|
|
118
|
+
// is fully self-contained (bundle + runtime + CSS inlined by the seam).
|
|
119
|
+
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
|
|
120
|
+
resources: [GATHERING_CARD_RESOURCE],
|
|
121
|
+
}));
|
|
122
|
+
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
123
|
+
switch (request.params.uri) {
|
|
124
|
+
case GATHERING_CARD_URI:
|
|
125
|
+
return readGatheringCardResource();
|
|
126
|
+
default:
|
|
127
|
+
throw new Error(`Unknown resource: ${request.params.uri}`);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
return server;
|
|
131
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { resolve, dirname } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
5
|
+
const __dirname = dirname(__filename);
|
|
6
|
+
// trim(): trailing whitespace from setx/cmd wrappers silently corrupts URLs
|
|
7
|
+
// and headers (KUKAMANGA-wide env-read rule).
|
|
8
|
+
const THEME_CSS_BASE = (process.env['RUNSNATIVE_THEME_CSS_BASE'] ?? 'https://runsnative.org').trim();
|
|
9
|
+
const FETCH_HEADERS = { 'User-Agent': 'runsnative-mcp-server (surface-assets)' };
|
|
10
|
+
let _bundleCache = null;
|
|
11
|
+
let _runtimeCache = null;
|
|
12
|
+
const _themeCssCache = new Map();
|
|
13
|
+
// In dist/, this module compiles to dist/surface/assets.js — both build-time
|
|
14
|
+
// artifacts sit one level up, in the shipped dist/ root.
|
|
15
|
+
function distPath(file) {
|
|
16
|
+
return resolve(__dirname, '..', file);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The run-* web-component bundle to inline into an MCP App surface.
|
|
20
|
+
*
|
|
21
|
+
* Read from dist/components-inline.js — a build-time flattening of the pinned
|
|
22
|
+
* @runsnative/components release into ONE self-contained ES module (the
|
|
23
|
+
* published runsnative.min.js statically imports ./chunks/*, so it cannot be
|
|
24
|
+
* inlined as-is). No network at tool-call time; the sandbox CSP never sees an
|
|
25
|
+
* external URL because the fetch problem is solved at build time.
|
|
26
|
+
*/
|
|
27
|
+
export function componentsBundle() {
|
|
28
|
+
if (_bundleCache === null) {
|
|
29
|
+
_bundleCache = readFileSync(distPath('components-inline.js'), 'utf8');
|
|
30
|
+
}
|
|
31
|
+
return _bundleCache;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The iframe-side MCP Apps runtime (App class + PostMessageTransport), built
|
|
35
|
+
* to dist/iframe-runtime.js as a self-contained IIFE exposing
|
|
36
|
+
* globalThis.__MCP_APP_SDK__.
|
|
37
|
+
*/
|
|
38
|
+
export function iframeRuntime() {
|
|
39
|
+
if (_runtimeCache === null) {
|
|
40
|
+
_runtimeCache = readFileSync(distPath('iframe-runtime.js'), 'utf8');
|
|
41
|
+
}
|
|
42
|
+
return _runtimeCache;
|
|
43
|
+
}
|
|
44
|
+
const BRAND_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
|
45
|
+
// Strip @import lines — webfont imports cannot load inside the sandboxed
|
|
46
|
+
// iframe and would only produce console noise (same transform compare.js
|
|
47
|
+
// applies for section-scoped theme loading, JUNE-95).
|
|
48
|
+
function stripImports(css) {
|
|
49
|
+
return css.replace(/^\s*@import[^;]*;\s*$/gm, '');
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Token-engine theme CSS for a brand (tokens-<brand>.css, already scoped to
|
|
53
|
+
* :where([data-run-theme="X"])). Sourced from the pinned components package
|
|
54
|
+
* first (dist/tokens/, offline, version-consistent with the inlined bundle);
|
|
55
|
+
* falls back to the runsnative.org public root for brands the pinned release
|
|
56
|
+
* doesn't carry (e.g. themes landed after the pin).
|
|
57
|
+
* Returns null when the brand has no landed theme file — the card then renders
|
|
58
|
+
* with the accent-hex fallback.
|
|
59
|
+
*/
|
|
60
|
+
export async function themeCss(brandId) {
|
|
61
|
+
if (!BRAND_ID_PATTERN.test(brandId))
|
|
62
|
+
return null;
|
|
63
|
+
if (_themeCssCache.has(brandId))
|
|
64
|
+
return _themeCssCache.get(brandId) ?? null;
|
|
65
|
+
let css = null;
|
|
66
|
+
// dist/tokens/ is populated at build time from the same pinned release as
|
|
67
|
+
// the inlined bundle — offline and version-consistent.
|
|
68
|
+
const packaged = distPath(`tokens/tokens-${brandId}.css`);
|
|
69
|
+
if (existsSync(packaged))
|
|
70
|
+
css = stripImports(readFileSync(packaged, 'utf8'));
|
|
71
|
+
if (css === null) {
|
|
72
|
+
try {
|
|
73
|
+
const res = await fetch(`${THEME_CSS_BASE}/tokens-${brandId}.css`, { headers: FETCH_HEADERS });
|
|
74
|
+
if (res.ok)
|
|
75
|
+
css = stripImports(await res.text());
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
css = null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
_themeCssCache.set(brandId, css);
|
|
82
|
+
return css;
|
|
83
|
+
}
|
|
84
|
+
/** Test seam — reset module-level caches between tests. */
|
|
85
|
+
export function _resetAssetCaches() {
|
|
86
|
+
_bundleCache = null;
|
|
87
|
+
_runtimeCache = null;
|
|
88
|
+
_themeCssCache.clear();
|
|
89
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The gathering-card MCP App shell (JUNE-618) — the Create-a-Gathering finale.
|
|
3
|
+
*
|
|
4
|
+
* Static shell: no per-call data is baked in. The host delivers the tool
|
|
5
|
+
* result (gathering + theme CSS) via the MCP Apps tool-result notification;
|
|
6
|
+
* the app script below renders the card client-side with DOM APIs
|
|
7
|
+
* (textContent only — user-authored fields can never parse as markup).
|
|
8
|
+
*/
|
|
9
|
+
import { packageSurface } from './package-surface.js';
|
|
10
|
+
export const GATHERING_CARD_URI = 'ui://runsnative/gathering-card.html';
|
|
11
|
+
const CARD_CSS = `
|
|
12
|
+
/* Shell defaults live in the lowest layer so injected token-engine CSS
|
|
13
|
+
(@layer primitives/semantics/themes, or unlayered scoped bundles) always
|
|
14
|
+
wins the cascade. Order is fixed by this first declaration. */
|
|
15
|
+
@layer rn-shell, primitives, semantics, themes;
|
|
16
|
+
@layer rn-shell {
|
|
17
|
+
:root {
|
|
18
|
+
--run-color-text-primary: #1e293b;
|
|
19
|
+
--run-color-text-secondary: #64748b;
|
|
20
|
+
--run-color-surface-default: #ffffff;
|
|
21
|
+
--run-color-surface-subtle: #f8fafc;
|
|
22
|
+
--run-color-surface-canvas: transparent;
|
|
23
|
+
--run-color-border-default: #e2e8f0;
|
|
24
|
+
--run-color-focus-ring: #2563eb;
|
|
25
|
+
--run-font-size-s: 14px;
|
|
26
|
+
--run-font-size-m: 16px;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
30
|
+
body {
|
|
31
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
32
|
+
color: var(--run-color-text-primary);
|
|
33
|
+
background: var(--run-color-surface-canvas);
|
|
34
|
+
padding: 12px;
|
|
35
|
+
}
|
|
36
|
+
.gathering-card {
|
|
37
|
+
max-width: 440px;
|
|
38
|
+
border: 1px solid var(--run-color-border-default);
|
|
39
|
+
border-radius: 12px;
|
|
40
|
+
background: var(--run-color-surface-default);
|
|
41
|
+
padding: 20px;
|
|
42
|
+
display: none;
|
|
43
|
+
flex-direction: column;
|
|
44
|
+
gap: 10px;
|
|
45
|
+
}
|
|
46
|
+
.gathering-card.is-ready { display: flex; }
|
|
47
|
+
.card-host-bar { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
|
48
|
+
.card-meta { display: flex; align-items: center; gap: 8px; margin-top: 2px; }
|
|
49
|
+
.card-avatar {
|
|
50
|
+
width: 28px; height: 28px; border-radius: 50%;
|
|
51
|
+
background: var(--run-color-brand-600, var(--card-accent, #2563eb)); color: #fff;
|
|
52
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
53
|
+
font-size: 11px; font-weight: 600; letter-spacing: 0.02em;
|
|
54
|
+
}
|
|
55
|
+
.card-actions { display: flex; align-items: center; gap: 10px; margin-top: 8px; }
|
|
56
|
+
.card-status { font-size: 12px; color: var(--run-color-text-secondary); min-height: 1.2em; }
|
|
57
|
+
#placeholder { font-size: 13px; color: var(--run-color-text-secondary); padding: 8px 2px; }
|
|
58
|
+
`;
|
|
59
|
+
const CARD_BODY = `
|
|
60
|
+
<div id="placeholder">Waiting for your gathering…</div>
|
|
61
|
+
<article class="gathering-card" id="card">
|
|
62
|
+
<div class="card-host-bar">
|
|
63
|
+
<run-text id="card-brand" variant="label" as="span" size="sm" color="theme"></run-text>
|
|
64
|
+
<run-badge variant="info" size="sm" pill>Yours</run-badge>
|
|
65
|
+
</div>
|
|
66
|
+
<run-text id="card-title" variant="title" as="h3" size="md"></run-text>
|
|
67
|
+
<run-text id="card-desc" variant="body" as="p" size="sm" color="secondary"></run-text>
|
|
68
|
+
<div class="card-meta">
|
|
69
|
+
<span class="card-avatar" id="card-avatar" aria-hidden="true"></span>
|
|
70
|
+
<run-text id="card-presenter" variant="caption" as="span"></run-text>
|
|
71
|
+
<run-text id="card-date" variant="caption" as="span" color="secondary"></run-text>
|
|
72
|
+
</div>
|
|
73
|
+
<div class="card-actions">
|
|
74
|
+
<run-button id="view-btn" variant="primary" size="sm">View on Convene</run-button>
|
|
75
|
+
</div>
|
|
76
|
+
<div class="card-status" id="card-status"></div>
|
|
77
|
+
</article>
|
|
78
|
+
`;
|
|
79
|
+
// Static, data-free by design: everything user-authored arrives at runtime via
|
|
80
|
+
// the tool-result notification and is assigned with textContent. Keep it that
|
|
81
|
+
// way — interpolating server-side values here would reopen the injection door
|
|
82
|
+
// the seam exists to close.
|
|
83
|
+
const CARD_APP_SCRIPT = `
|
|
84
|
+
const { App } = globalThis.__MCP_APP_SDK__;
|
|
85
|
+
|
|
86
|
+
const BRAND_ID = /^[a-z0-9][a-z0-9-]*$/;
|
|
87
|
+
const ACCENT_HEX = /^#[0-9a-fA-F]{3,8}$/;
|
|
88
|
+
|
|
89
|
+
function setText(id, value) {
|
|
90
|
+
document.getElementById(id).textContent = value == null ? '' : String(value);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function render(payload) {
|
|
94
|
+
const g = payload && payload.gathering;
|
|
95
|
+
if (!g) return;
|
|
96
|
+
const card = document.getElementById('card');
|
|
97
|
+
|
|
98
|
+
// Theme: landed brands get their token-engine CSS (scoped to
|
|
99
|
+
// :where([data-run-theme="X"])); everything else falls back to the accent.
|
|
100
|
+
if (typeof payload.theme_css === 'string' && payload.theme_css && BRAND_ID.test(g.brand_id || '')) {
|
|
101
|
+
const style = document.createElement('style');
|
|
102
|
+
style.textContent = payload.theme_css;
|
|
103
|
+
document.head.appendChild(style);
|
|
104
|
+
card.setAttribute('data-run-theme', g.brand_id);
|
|
105
|
+
document.documentElement.setAttribute('data-run-theme', g.brand_id);
|
|
106
|
+
} else if (typeof g.accent_hex === 'string' && ACCENT_HEX.test(g.accent_hex)) {
|
|
107
|
+
// No landed theme — carry the user's accent onto the tokens the card's
|
|
108
|
+
// components actually consume.
|
|
109
|
+
card.style.setProperty('--card-accent', g.accent_hex);
|
|
110
|
+
card.style.setProperty('--run-color-focus-ring', g.accent_hex);
|
|
111
|
+
card.style.setProperty('--run-button-background', g.accent_hex);
|
|
112
|
+
card.style.setProperty('--run-button-background-hover', g.accent_hex);
|
|
113
|
+
card.style.setProperty('--run-button-background-active', g.accent_hex);
|
|
114
|
+
card.style.setProperty('--run-button-color', '#ffffff');
|
|
115
|
+
card.style.setProperty('--run-button-focus-ring-color', g.accent_hex);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
setText('card-brand', payload.brand_label || g.brand_id || 'RunsNative');
|
|
119
|
+
setText('card-title', g.title);
|
|
120
|
+
setText('card-desc', g.description);
|
|
121
|
+
setText('card-avatar', (g.presenter_initials || '').slice(0, 3).toUpperCase());
|
|
122
|
+
setText('card-presenter', g.presenter_name);
|
|
123
|
+
setText('card-date', g.date_string);
|
|
124
|
+
|
|
125
|
+
document.getElementById('placeholder').style.display = 'none';
|
|
126
|
+
card.classList.add('is-ready');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const app = new App({ name: 'runsnative-gathering-card', version: '1.0.0' }, {});
|
|
130
|
+
|
|
131
|
+
// Register before connect() so no notification is missed.
|
|
132
|
+
app.ontoolresult = (params) => {
|
|
133
|
+
if (params && params.isError) {
|
|
134
|
+
setText('placeholder', 'Could not load your gathering — see the conversation for details.');
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
render(params && params.structuredContent);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
document.getElementById('view-btn').addEventListener('click', async () => {
|
|
141
|
+
const status = document.getElementById('card-status');
|
|
142
|
+
status.textContent = 'Sending your tab to Convene…';
|
|
143
|
+
try {
|
|
144
|
+
const result = await app.callServerTool({ name: 'navigate', arguments: { path: '/compare/' } });
|
|
145
|
+
status.textContent = result && result.isError
|
|
146
|
+
? 'No linked tab — open runsnative.org/compare/ to see it live.'
|
|
147
|
+
: 'Your linked tab is heading to Convene.';
|
|
148
|
+
} catch (e) {
|
|
149
|
+
status.textContent = 'No linked tab — open runsnative.org/compare/ to see it live.';
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
await app.connect();
|
|
154
|
+
`;
|
|
155
|
+
export function buildGatheringCardHtml(assets) {
|
|
156
|
+
return packageSurface({
|
|
157
|
+
title: 'Your Gathering — Convene',
|
|
158
|
+
bodyHtml: CARD_BODY,
|
|
159
|
+
css: CARD_CSS,
|
|
160
|
+
appScript: CARD_APP_SCRIPT,
|
|
161
|
+
}, assets);
|
|
162
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Entry for the iframe-side MCP Apps runtime bundle. Built by
|
|
2
|
+
// scripts/build-iframe-runtime.mjs into dist/iframe-runtime.js as a
|
|
3
|
+
// self-contained IIFE, then inlined into every packaged surface by the seam
|
|
4
|
+
// (package-surface.ts). Exposes a stable global so the (also inlined) app
|
|
5
|
+
// script can use the SDK without module imports — inline module scripts
|
|
6
|
+
// cannot import each other, and external URLs are blocked by the host CSP.
|
|
7
|
+
import { App } from '@modelcontextprotocol/ext-apps';
|
|
8
|
+
globalThis.__MCP_APP_SDK__ = { App };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The MCP App packaging seam (JUNE-618) — component tree + theme + app script
|
|
3
|
+
* into ONE self-contained `ui://` resource document.
|
|
4
|
+
*
|
|
5
|
+
* This is the mold-shaped boundary: every host-specific quirk of rendering
|
|
6
|
+
* RunsNative surfaces inside an agent host lives in this module (and the
|
|
7
|
+
* assets it inlines), NEVER in components. Contract:
|
|
8
|
+
* mukadra docs/contracts/agent-surface-packaging-contract-v1.md.
|
|
9
|
+
*
|
|
10
|
+
* Invariants:
|
|
11
|
+
* - The emitted document references ZERO external URLs. The component bundle,
|
|
12
|
+
* the MCP Apps iframe runtime, and all CSS ride inside the document —
|
|
13
|
+
* the host sandbox CSP blocks external loads (RUN-477 / in-host verify).
|
|
14
|
+
* - Data does NOT ride in the document. The shell is static; per-call data
|
|
15
|
+
* arrives through the MCP Apps tool-result notification and is rendered
|
|
16
|
+
* client-side with DOM APIs (textContent), never markup interpolation.
|
|
17
|
+
*/
|
|
18
|
+
// MIME type for MCP App resources, per ext-apps (RESOURCE_MIME_TYPE). Kept as
|
|
19
|
+
// a literal so this module stays dependency-free and pure.
|
|
20
|
+
export const SURFACE_MIME_TYPE = 'text/html;profile=mcp-app';
|
|
21
|
+
export function escapeHtml(s) {
|
|
22
|
+
return String(s ?? '')
|
|
23
|
+
.replace(/&/g, '&')
|
|
24
|
+
.replace(/</g, '<')
|
|
25
|
+
.replace(/>/g, '>')
|
|
26
|
+
.replace(/"/g, '"')
|
|
27
|
+
.replace(/'/g, ''');
|
|
28
|
+
}
|
|
29
|
+
// A closing </script> inside inlined JS text would terminate the surrounding
|
|
30
|
+
// script element early and let the remainder parse as markup. Break the
|
|
31
|
+
// sequence — safe inside JS strings and regexes alike.
|
|
32
|
+
function escapeInlineScript(js) {
|
|
33
|
+
return js.replace(/<\/script/gi, '<\\/script');
|
|
34
|
+
}
|
|
35
|
+
function escapeInlineStyle(css) {
|
|
36
|
+
return css.replace(/<\/style/gi, '<\\/style');
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Package a surface into a single self-contained HTML document.
|
|
40
|
+
* Pure function: assets are injected so tests need no network or filesystem.
|
|
41
|
+
*/
|
|
42
|
+
export function packageSurface(input, assets) {
|
|
43
|
+
const { title, bodyHtml, css, appScript } = input;
|
|
44
|
+
return `<!DOCTYPE html>
|
|
45
|
+
<html lang="en" data-run-mode="light">
|
|
46
|
+
<head>
|
|
47
|
+
<meta charset="UTF-8">
|
|
48
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
49
|
+
<title>${escapeHtml(title)}</title>
|
|
50
|
+
<style>${escapeInlineStyle(css ?? '')}</style>
|
|
51
|
+
</head>
|
|
52
|
+
<body>
|
|
53
|
+
${bodyHtml}
|
|
54
|
+
<script type="module">${escapeInlineScript(assets.componentsBundle)}</script>
|
|
55
|
+
<script>${escapeInlineScript(assets.iframeRuntime)}</script>
|
|
56
|
+
<script type="module">${escapeInlineScript(appScript ?? '')}</script>
|
|
57
|
+
</body>
|
|
58
|
+
</html>`;
|
|
59
|
+
}
|