@runsnative/mcp-server 0.1.5 → 0.3.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/dist/bridge-client.js +25 -0
- package/dist/http-server.js +8 -2
- package/dist/index.js +28 -0
- package/dist/inferrer/contrast.js +28 -0
- package/dist/inferrer/image-extractor.js +31 -2
- package/dist/inferrer/page-fetcher.js +93 -7
- package/dist/inferrer/pipeline.js +3 -10
- package/dist/inferrer/style-extractor.js +125 -70
- package/dist/inferrer/theme-inferrer.js +115 -8
- package/dist/tools/get-completeness-map.js +24 -0
- package/dist/tools/get-emphasis-scale.js +55 -0
- package/dist/tools/get-marker-capture.js +91 -0
- package/dist/tools/link-session.js +35 -0
- package/dist/tools/navigate.js +61 -0
- package/dist/tools/set-instance-variant.js +85 -0
- package/dist/tools/set-theme.js +61 -0
- package/package.json +3 -2
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Module-level pair state — lives for the lifetime of the MCP server process.
|
|
2
|
+
let _linkedPairId = null;
|
|
3
|
+
export function getLinkedPairId() { return _linkedPairId; }
|
|
4
|
+
export function setLinkedPairId(id) { _linkedPairId = id; }
|
|
5
|
+
export function clearLinkedPairId() { _linkedPairId = null; }
|
|
6
|
+
// trim(): trailing whitespace from setx/cmd wrappers silently corrupts the
|
|
7
|
+
// Authorization header (KUKAMANGA-wide env-read rule).
|
|
8
|
+
const BASE_URL = (process.env['RUNSNATIVE_API_URL'] ?? 'https://api.runsnative.org/mcp').trim();
|
|
9
|
+
const TOKEN = process.env['RUNSNATIVE_TENANT_TOKEN']?.trim();
|
|
10
|
+
export async function postWorkerApi(path, body) {
|
|
11
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
12
|
+
if (TOKEN)
|
|
13
|
+
headers['Authorization'] = `Bearer ${TOKEN}`;
|
|
14
|
+
return fetch(`${BASE_URL}${path}`, {
|
|
15
|
+
method: 'POST',
|
|
16
|
+
headers,
|
|
17
|
+
body: JSON.stringify(body),
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
export async function getWorkerApi(path) {
|
|
21
|
+
const headers = {};
|
|
22
|
+
if (TOKEN)
|
|
23
|
+
headers['Authorization'] = `Bearer ${TOKEN}`;
|
|
24
|
+
return fetch(`${BASE_URL}${path}`, { method: 'GET', headers });
|
|
25
|
+
}
|
package/dist/http-server.js
CHANGED
|
@@ -116,8 +116,14 @@ async function handler(req, res) {
|
|
|
116
116
|
}
|
|
117
117
|
const targetUrl = body['url'];
|
|
118
118
|
const result = await runInferencePipeline(targetUrl);
|
|
119
|
-
if (!result.ok)
|
|
120
|
-
return sendJson(res, 422, {
|
|
119
|
+
if (!result.ok) {
|
|
120
|
+
return sendJson(res, 422, {
|
|
121
|
+
error: result.error.message,
|
|
122
|
+
code: result.error.code,
|
|
123
|
+
reason: result.error.reason,
|
|
124
|
+
...('status' in result.error && result.error.status ? { status: result.error.status } : {}),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
121
127
|
return sendJson(res, 200, result.result);
|
|
122
128
|
}
|
|
123
129
|
if (method === 'POST' && url === '/api/config') {
|
package/dist/index.js
CHANGED
|
@@ -15,6 +15,13 @@ import { GET_STEP_TOOL, handleGetStep } from './tools/get-step.js';
|
|
|
15
15
|
import { SURFACE_PREVIEW_TOOL, handleSurfacePreview } from './tools/surface-preview.js';
|
|
16
16
|
import { LIST_COMPOSITION_PATTERNS_TOOL, handleListCompositionPatterns } from './tools/list-composition-patterns.js';
|
|
17
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';
|
|
18
25
|
import { createProvider } from './provider.js';
|
|
19
26
|
import { RemoteContentProvider } from './remote-provider.js';
|
|
20
27
|
// Create the content provider — LocalContentProvider if content tree is present,
|
|
@@ -43,6 +50,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
43
50
|
SURFACE_PREVIEW_TOOL,
|
|
44
51
|
LIST_COMPOSITION_PATTERNS_TOOL,
|
|
45
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,
|
|
46
60
|
],
|
|
47
61
|
}));
|
|
48
62
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
@@ -71,6 +85,20 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
71
85
|
return handleListCompositionPatterns(provider, request.params.arguments ?? {});
|
|
72
86
|
case 'get_composition_pattern':
|
|
73
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 ?? {});
|
|
74
102
|
default:
|
|
75
103
|
throw new Error(`Unknown tool: ${request.params.name}`);
|
|
76
104
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** WCAG 2.2 relative luminance (https://www.w3.org/TR/WCAG22/#dfn-relative-luminance). */
|
|
2
|
+
export function relativeLuminance([r, g, b]) {
|
|
3
|
+
const linear = (channel) => {
|
|
4
|
+
const s = channel / 255;
|
|
5
|
+
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
|
6
|
+
};
|
|
7
|
+
return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
|
|
8
|
+
}
|
|
9
|
+
/** WCAG 2.2 contrast ratio. Symmetric: argument order does not matter. */
|
|
10
|
+
export function contrastRatio(a, b) {
|
|
11
|
+
const la = relativeLuminance(a);
|
|
12
|
+
const lb = relativeLuminance(b);
|
|
13
|
+
const lighter = Math.max(la, lb);
|
|
14
|
+
const darker = Math.min(la, lb);
|
|
15
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
16
|
+
}
|
|
17
|
+
export function assessContrast(foreground, background, backgroundHex) {
|
|
18
|
+
const raw = contrastRatio(foreground, background);
|
|
19
|
+
return {
|
|
20
|
+
// Thresholds test the unrounded ratio: a true 4.497 displays as 4.5 but does not pass AA.
|
|
21
|
+
ratio: Math.round(raw * 100) / 100,
|
|
22
|
+
backgroundColor: backgroundHex,
|
|
23
|
+
aa: raw >= 4.5,
|
|
24
|
+
aaLarge: raw >= 3,
|
|
25
|
+
aaa: raw >= 7,
|
|
26
|
+
aaaLarge: raw >= 4.5,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -10,6 +10,34 @@ async function loadSharp() {
|
|
|
10
10
|
}
|
|
11
11
|
const MAX_PIXELS = 10_000;
|
|
12
12
|
const MAX_ITER = 20;
|
|
13
|
+
/**
|
|
14
|
+
* k-means++ needs randomness, but Math.random() made this a different answer for the
|
|
15
|
+
* same bytes on every call. Seeding from the pixels keeps the clustering stochastic
|
|
16
|
+
* across images while making extractImageColors a pure function of its input.
|
|
17
|
+
*
|
|
18
|
+
* This buys reproducibility per *image*, not per *URL*: a live page can paint a
|
|
19
|
+
* different screenshot on each fetch (lazy content, animation), and no seeding fixes
|
|
20
|
+
* that. Re-analysing a stored screenshot, however, now always agrees with itself.
|
|
21
|
+
*/
|
|
22
|
+
function mulberry32(seed) {
|
|
23
|
+
let a = seed >>> 0;
|
|
24
|
+
return () => {
|
|
25
|
+
a = (a + 0x6d2b79f5) >>> 0;
|
|
26
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
27
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
28
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/** FNV-1a over the sampled pixels. */
|
|
32
|
+
function seedFromPixels(pixels) {
|
|
33
|
+
let h = 0x811c9dc5;
|
|
34
|
+
for (const [r, g, b] of pixels) {
|
|
35
|
+
h = Math.imul(h ^ r, 0x01000193);
|
|
36
|
+
h = Math.imul(h ^ g, 0x01000193);
|
|
37
|
+
h = Math.imul(h ^ b, 0x01000193);
|
|
38
|
+
}
|
|
39
|
+
return h >>> 0;
|
|
40
|
+
}
|
|
13
41
|
function toHex(r, g, b) {
|
|
14
42
|
return '#' + [r, g, b]
|
|
15
43
|
.map(v => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0'))
|
|
@@ -36,12 +64,13 @@ export async function extractImageColors(buffer, sourceType, k = 5) {
|
|
|
36
64
|
pixels.push([data[i], data[i + 1], data[i + 2]]);
|
|
37
65
|
}
|
|
38
66
|
// k-means++ initialisation
|
|
67
|
+
const random = mulberry32(seedFromPixels(pixels));
|
|
39
68
|
const centroids = [];
|
|
40
|
-
centroids.push([...pixels[Math.floor(
|
|
69
|
+
centroids.push([...pixels[Math.floor(random() * pixels.length)]]);
|
|
41
70
|
for (let c = 1; c < k; c++) {
|
|
42
71
|
const dists = pixels.map(p => Math.min(...centroids.map(cen => sqDist(p[0], p[1], p[2], cen[0], cen[1], cen[2]))));
|
|
43
72
|
const total = dists.reduce((s, d) => s + d, 0);
|
|
44
|
-
let rnd =
|
|
73
|
+
let rnd = random() * total;
|
|
45
74
|
let chosen = pixels.length - 1;
|
|
46
75
|
for (let i = 0; i < dists.length; i++) {
|
|
47
76
|
rnd -= dists[i];
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { extractStylesFromPage } from './style-extractor.js';
|
|
1
2
|
export async function loadChromium() {
|
|
2
3
|
try {
|
|
3
4
|
const playwright = await import('playwright');
|
|
@@ -7,17 +8,56 @@ export async function loadChromium() {
|
|
|
7
8
|
return null;
|
|
8
9
|
}
|
|
9
10
|
}
|
|
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
|
+
}
|
|
10
37
|
const VIEWPORT = { width: 1280, height: 800 };
|
|
38
|
+
// Navigation timing. `domcontentloaded` is the hard gate; `networkidle` is a bounded bonus.
|
|
39
|
+
const GOTO_TIMEOUT_MS = 25_000; // hard cap on the initial document navigation
|
|
40
|
+
const NETWORK_IDLE_TIMEOUT_MS = 4_000; // best-effort quiesce; a busy site just skips it
|
|
41
|
+
const SETTLE_MS = 800; // final paint after the DOM is ready
|
|
42
|
+
const SCREENSHOT_TIMEOUT_MS = 8_000; // best-effort; empty buffer on a page that won't settle
|
|
43
|
+
/**
|
|
44
|
+
* Navigates once and harvests everything the page can tell us before the browser closes:
|
|
45
|
+
* the screenshot, the serialized DOM, and the computed styles.
|
|
46
|
+
*
|
|
47
|
+
* Style extraction happens *here*, on the live page, rather than by re-rendering
|
|
48
|
+
* `renderedHtml` in a second browser — that resolves the document against about:blank and
|
|
49
|
+
* silently drops relatively-linked stylesheets, yielding an unstyled measurement.
|
|
50
|
+
*/
|
|
11
51
|
export async function fetchAndRender(url) {
|
|
12
52
|
let parsed;
|
|
13
53
|
try {
|
|
14
54
|
parsed = new URL(url);
|
|
15
55
|
}
|
|
16
56
|
catch {
|
|
17
|
-
return { ok: false, error: { code: 'FETCH_ERROR', message: 'Invalid URL', url } };
|
|
57
|
+
return { ok: false, error: { code: 'FETCH_ERROR', message: 'Invalid URL', reason: 'invalid_url', url } };
|
|
18
58
|
}
|
|
19
59
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
20
|
-
return { ok: false, error: { code: 'FETCH_ERROR', message: `Unsupported scheme: ${parsed.protocol}`, url } };
|
|
60
|
+
return { ok: false, error: { code: 'FETCH_ERROR', message: `Unsupported scheme: ${parsed.protocol}`, reason: 'invalid_url', url } };
|
|
21
61
|
}
|
|
22
62
|
const chromium = await loadChromium();
|
|
23
63
|
if (chromium === null) {
|
|
@@ -26,6 +66,7 @@ export async function fetchAndRender(url) {
|
|
|
26
66
|
error: {
|
|
27
67
|
code: 'FETCH_ERROR',
|
|
28
68
|
message: 'playwright is not installed — infer_brand_theme requires it. Install playwright, or use infer_theme for HTML-only analysis.',
|
|
69
|
+
reason: 'unknown',
|
|
29
70
|
url,
|
|
30
71
|
},
|
|
31
72
|
};
|
|
@@ -34,18 +75,62 @@ export async function fetchAndRender(url) {
|
|
|
34
75
|
try {
|
|
35
76
|
const page = await browser.newPage();
|
|
36
77
|
await page.setViewportSize(VIEWPORT);
|
|
37
|
-
|
|
78
|
+
// Wait for the DOM, then give the network a *bounded* chance to quiesce. Real sites —
|
|
79
|
+
// anything with analytics, a chat widget, or long-poll — never reach `networkidle`, so
|
|
80
|
+
// using it as the goto gate turned a renderable page into a 30s timeout (airbnb.com was
|
|
81
|
+
// a 30s failure; with this it renders in ~6s). `domcontentloaded` returns as soon as the
|
|
82
|
+
// markup+CSS are parsed; the short best-effort `networkidle` catches late-injected styles
|
|
83
|
+
// on calmer sites without holding the busy ones hostage; the settle covers a final paint.
|
|
84
|
+
const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: GOTO_TIMEOUT_MS });
|
|
38
85
|
if (!response) {
|
|
39
|
-
return { ok: false, error: { code: 'FETCH_ERROR', message: 'Navigation returned no response', url } };
|
|
86
|
+
return { ok: false, error: { code: 'FETCH_ERROR', message: 'Navigation returned no response', reason: 'unreachable', url } };
|
|
87
|
+
}
|
|
88
|
+
// A 4xx/5xx status still returns a rendered body, so goto does NOT throw — without this
|
|
89
|
+
// check we would infer a theme from an "Access Denied" or "Not Found" page. 403/429 read
|
|
90
|
+
// as a bot-block (the message a person needs); other 4xx/5xx as a plain HTTP error.
|
|
91
|
+
const status = response.status();
|
|
92
|
+
if (status >= 400) {
|
|
93
|
+
const reason = status === 403 || status === 429 ? 'blocked' : 'http_error';
|
|
94
|
+
return { ok: false, error: { code: 'FETCH_ERROR', message: `Page returned HTTP ${status}`, reason, url, status } };
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
await page.waitForLoadState('networkidle', { timeout: NETWORK_IDLE_TIMEOUT_MS });
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// Busy site — the network never idled. Proceed with what has painted.
|
|
40
101
|
}
|
|
102
|
+
await page.waitForTimeout(SETTLE_MS);
|
|
41
103
|
const htmlSource = await response.text();
|
|
42
104
|
const renderedHtml = await page.content();
|
|
43
|
-
|
|
105
|
+
// Best-effort, bounded. A `fullPage` screenshot waits for in-flight requests to settle,
|
|
106
|
+
// so on a page that never idles (the same busy sites the navigation change rescues) it
|
|
107
|
+
// would hang until its own 30s timeout and fail the whole inference. The screenshot only
|
|
108
|
+
// feeds screenshot-colour extraction, which the pipeline already treats as optional
|
|
109
|
+
// (extractImageColors is try/caught) — the styles are the primary signal. So cap it and,
|
|
110
|
+
// on timeout, proceed with an empty buffer rather than sink the request.
|
|
111
|
+
let screenshot;
|
|
112
|
+
try {
|
|
113
|
+
screenshot = await page.screenshot({ fullPage: true, timeout: SCREENSHOT_TIMEOUT_MS });
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
screenshot = Buffer.alloc(0);
|
|
117
|
+
}
|
|
44
118
|
const title = await page.title();
|
|
119
|
+
const resolvedUrl = page.url();
|
|
120
|
+
// Returned from inside the try so the outer catch cannot relabel an extraction
|
|
121
|
+
// failure as a FETCH_ERROR; `finally` still closes the browser.
|
|
122
|
+
let evidence;
|
|
123
|
+
try {
|
|
124
|
+
evidence = await extractStylesFromPage(page, resolvedUrl);
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
const cause = err instanceof Error ? err.message : String(err);
|
|
128
|
+
return { ok: false, error: { code: 'EXTRACTION_ERROR', message: 'Style extraction failed', reason: 'unreadable', url, cause } };
|
|
129
|
+
}
|
|
45
130
|
return {
|
|
46
131
|
ok: true,
|
|
47
132
|
snapshot: {
|
|
48
|
-
url:
|
|
133
|
+
url: resolvedUrl,
|
|
49
134
|
htmlSource,
|
|
50
135
|
renderedHtml,
|
|
51
136
|
screenshot,
|
|
@@ -53,11 +138,12 @@ export async function fetchAndRender(url) {
|
|
|
53
138
|
title,
|
|
54
139
|
fetchedAt: new Date().toISOString(),
|
|
55
140
|
},
|
|
141
|
+
evidence,
|
|
56
142
|
};
|
|
57
143
|
}
|
|
58
144
|
catch (err) {
|
|
59
145
|
const cause = err instanceof Error ? err.message : String(err);
|
|
60
|
-
return { ok: false, error: { code: 'FETCH_ERROR', message: 'Headless render failed', url, cause } };
|
|
146
|
+
return { ok: false, error: { code: 'FETCH_ERROR', message: 'Headless render failed', reason: classifyNavError(cause), url, cause } };
|
|
61
147
|
}
|
|
62
148
|
finally {
|
|
63
149
|
await browser.close();
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { fetchAndRender } from './page-fetcher.js';
|
|
2
|
-
import { extractStyles } from './style-extractor.js';
|
|
3
2
|
import { extractImageColors } from './image-extractor.js';
|
|
4
3
|
import { inferTheme } from './theme-inferrer.js';
|
|
5
4
|
export async function runInferencePipeline(url) {
|
|
5
|
+
// One navigation yields both the screenshot and the computed styles — the styles are
|
|
6
|
+
// read on the live page, where its stylesheets actually loaded.
|
|
6
7
|
const fetchResult = await fetchAndRender(url);
|
|
7
8
|
if (!fetchResult.ok)
|
|
8
9
|
return { ok: false, error: fetchResult.error };
|
|
9
|
-
const { snapshot } = fetchResult;
|
|
10
|
+
const { snapshot, evidence } = fetchResult;
|
|
10
11
|
let imageEvidence = undefined;
|
|
11
12
|
try {
|
|
12
13
|
imageEvidence = await extractImageColors(snapshot.screenshot, 'screenshot');
|
|
@@ -14,14 +15,6 @@ export async function runInferencePipeline(url) {
|
|
|
14
15
|
catch {
|
|
15
16
|
// Image extraction is best-effort — proceed without it.
|
|
16
17
|
}
|
|
17
|
-
let evidence;
|
|
18
|
-
try {
|
|
19
|
-
evidence = await extractStyles(snapshot);
|
|
20
|
-
}
|
|
21
|
-
catch (err) {
|
|
22
|
-
const cause = err instanceof Error ? err.message : String(err);
|
|
23
|
-
return { ok: false, error: { code: 'EXTRACTION_ERROR', message: 'Style extraction failed', url: snapshot.url, cause } };
|
|
24
|
-
}
|
|
25
18
|
const result = inferTheme(evidence, imageEvidence);
|
|
26
19
|
return { ok: true, result };
|
|
27
20
|
}
|
|
@@ -1,79 +1,134 @@
|
|
|
1
|
-
import { loadChromium } from './page-fetcher.js';
|
|
2
1
|
const SAMPLE_SELECTORS = 'body,h1,h2,h3,h4,h5,h6,p,a,button,input,select,textarea,' +
|
|
3
2
|
'div,span,header,footer,nav,main,section,article,aside,ul,ol,li';
|
|
4
3
|
const MAX_ELEMENTS = 200;
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
if (v && v !== 'rgba(0, 0, 0, 0)')
|
|
31
|
-
colors.add(v);
|
|
4
|
+
/**
|
|
5
|
+
* Reads computed styles from a page that has already navigated to the real URL.
|
|
6
|
+
*
|
|
7
|
+
* It must be the live page. Re-rendering `page.content()` into a blank page via
|
|
8
|
+
* setContent() resolves the document against about:blank, so a site whose CSS lives in a
|
|
9
|
+
* relatively-linked stylesheet is measured completely unstyled — dembrandt.com reported a
|
|
10
|
+
* white background (it is black), zero radii, zero shadows and zero durations, all at
|
|
11
|
+
* "high" confidence. The styles must be read where the stylesheets actually loaded.
|
|
12
|
+
*/
|
|
13
|
+
export async function extractStylesFromPage(page, sourceUrl) {
|
|
14
|
+
const raw = await page.evaluate(({ selectors, maxElements }) => {
|
|
15
|
+
const TRANSPARENT = 'rgba(0, 0, 0, 0)';
|
|
16
|
+
// Split a comma-separated CSS list without cutting inside cubic-bezier(...).
|
|
17
|
+
const splitCssList = (value) => {
|
|
18
|
+
const parts = [];
|
|
19
|
+
let depth = 0;
|
|
20
|
+
let current = '';
|
|
21
|
+
for (const ch of value) {
|
|
22
|
+
if (ch === '(')
|
|
23
|
+
depth++;
|
|
24
|
+
else if (ch === ')')
|
|
25
|
+
depth--;
|
|
26
|
+
if (ch === ',' && depth === 0) {
|
|
27
|
+
parts.push(current.trim());
|
|
28
|
+
current = '';
|
|
32
29
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
fontFamilies.add(ff);
|
|
36
|
-
const fs = cs.getPropertyValue('font-size');
|
|
37
|
-
if (fs && fs !== '0px')
|
|
38
|
-
fontSizes.add(fs);
|
|
39
|
-
for (const prop of [
|
|
40
|
-
'border-top-left-radius', 'border-top-right-radius',
|
|
41
|
-
'border-bottom-right-radius', 'border-bottom-left-radius',
|
|
42
|
-
]) {
|
|
43
|
-
const v = cs.getPropertyValue(prop);
|
|
44
|
-
if (v && v !== '0px')
|
|
45
|
-
borderRadii.add(v);
|
|
46
|
-
}
|
|
47
|
-
for (const prop of ['box-shadow', 'text-shadow']) {
|
|
48
|
-
const v = cs.getPropertyValue(prop);
|
|
49
|
-
if (v && v !== 'none')
|
|
50
|
-
shadows.add(v);
|
|
51
|
-
}
|
|
52
|
-
for (const prop of [
|
|
53
|
-
'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
|
|
54
|
-
'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
|
|
55
|
-
]) {
|
|
56
|
-
const v = cs.getPropertyValue(prop);
|
|
57
|
-
if (v && v !== '0px')
|
|
58
|
-
spacings.add(v);
|
|
30
|
+
else {
|
|
31
|
+
current += ch;
|
|
59
32
|
}
|
|
60
33
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
34
|
+
if (current.trim())
|
|
35
|
+
parts.push(current.trim());
|
|
36
|
+
return parts;
|
|
37
|
+
};
|
|
38
|
+
const durationMs = (value) => {
|
|
39
|
+
const m = /^([\d.]+)(ms|s)$/.exec(value);
|
|
40
|
+
if (!m)
|
|
41
|
+
return null;
|
|
42
|
+
const n = parseFloat(m[1]);
|
|
43
|
+
return m[2] === 's' ? n * 1000 : n;
|
|
44
|
+
};
|
|
45
|
+
const elements = Array.from(document.querySelectorAll(selectors)).slice(0, maxElements);
|
|
46
|
+
const colors = new Set();
|
|
47
|
+
const fontFamilies = new Set();
|
|
48
|
+
const fontSizes = new Set();
|
|
49
|
+
const borderRadii = new Set();
|
|
50
|
+
const shadows = new Set();
|
|
51
|
+
const spacings = new Set();
|
|
52
|
+
const motionDurations = new Set();
|
|
53
|
+
const motionEasings = new Set();
|
|
54
|
+
// CSS cycles the shorter list against the longer one, so a timing function is
|
|
55
|
+
// only meaningful next to the duration it is actually paired with.
|
|
56
|
+
const collectMotion = (cs, durationProp, easingProp) => {
|
|
57
|
+
const durations = splitCssList(cs.getPropertyValue(durationProp) || '');
|
|
58
|
+
const easings = splitCssList(cs.getPropertyValue(easingProp) || '');
|
|
59
|
+
if (easings.length === 0)
|
|
60
|
+
return;
|
|
61
|
+
durations.forEach((d, i) => {
|
|
62
|
+
const ms = durationMs(d);
|
|
63
|
+
if (ms === null || ms <= 0)
|
|
64
|
+
return;
|
|
65
|
+
motionDurations.add(d);
|
|
66
|
+
motionEasings.add(easings[i % easings.length]);
|
|
67
|
+
});
|
|
68
|
+
};
|
|
69
|
+
for (const el of elements) {
|
|
70
|
+
const cs = window.getComputedStyle(el);
|
|
71
|
+
for (const prop of [
|
|
72
|
+
'color', 'background-color', 'border-top-color',
|
|
73
|
+
'border-right-color', 'border-bottom-color', 'border-left-color', 'outline-color',
|
|
74
|
+
]) {
|
|
75
|
+
const v = cs.getPropertyValue(prop);
|
|
76
|
+
if (v && v !== TRANSPARENT)
|
|
77
|
+
colors.add(v);
|
|
78
|
+
}
|
|
79
|
+
const ff = cs.getPropertyValue('font-family');
|
|
80
|
+
if (ff)
|
|
81
|
+
fontFamilies.add(ff);
|
|
82
|
+
const fs = cs.getPropertyValue('font-size');
|
|
83
|
+
if (fs && fs !== '0px')
|
|
84
|
+
fontSizes.add(fs);
|
|
85
|
+
for (const prop of [
|
|
86
|
+
'border-top-left-radius', 'border-top-right-radius',
|
|
87
|
+
'border-bottom-right-radius', 'border-bottom-left-radius',
|
|
88
|
+
]) {
|
|
89
|
+
const v = cs.getPropertyValue(prop);
|
|
90
|
+
if (v && v !== '0px')
|
|
91
|
+
borderRadii.add(v);
|
|
92
|
+
}
|
|
93
|
+
for (const prop of ['box-shadow', 'text-shadow']) {
|
|
94
|
+
const v = cs.getPropertyValue(prop);
|
|
95
|
+
if (v && v !== 'none')
|
|
96
|
+
shadows.add(v);
|
|
97
|
+
}
|
|
98
|
+
for (const prop of [
|
|
99
|
+
'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
|
|
100
|
+
'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
|
|
101
|
+
]) {
|
|
102
|
+
const v = cs.getPropertyValue(prop);
|
|
103
|
+
if (v && v !== '0px')
|
|
104
|
+
spacings.add(v);
|
|
105
|
+
}
|
|
106
|
+
collectMotion(cs, 'transition-duration', 'transition-timing-function');
|
|
107
|
+
collectMotion(cs, 'animation-duration', 'animation-timing-function');
|
|
108
|
+
}
|
|
109
|
+
// Neither <body> nor <html> painting a background means the CSS canvas shows
|
|
110
|
+
// through, which a default-scheme Chromium renders white.
|
|
111
|
+
const surface = document.body ?? document.documentElement;
|
|
112
|
+
const bodyBg = surface ? window.getComputedStyle(surface).getPropertyValue('background-color') : '';
|
|
113
|
+
const htmlBg = window.getComputedStyle(document.documentElement).getPropertyValue('background-color');
|
|
114
|
+
const backgroundColor = bodyBg && bodyBg !== TRANSPARENT ? bodyBg
|
|
115
|
+
: htmlBg && htmlBg !== TRANSPARENT ? htmlBg
|
|
116
|
+
: 'rgb(255, 255, 255)';
|
|
70
117
|
return {
|
|
71
|
-
...
|
|
72
|
-
|
|
73
|
-
|
|
118
|
+
colors: [...colors],
|
|
119
|
+
fontFamilies: [...fontFamilies],
|
|
120
|
+
fontSizes: [...fontSizes],
|
|
121
|
+
borderRadii: [...borderRadii],
|
|
122
|
+
shadows: [...shadows],
|
|
123
|
+
spacings: [...spacings],
|
|
124
|
+
motionDurations: [...motionDurations],
|
|
125
|
+
motionEasings: [...motionEasings],
|
|
126
|
+
backgroundColor,
|
|
74
127
|
};
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
128
|
+
}, { selectors: SAMPLE_SELECTORS, maxElements: MAX_ELEMENTS });
|
|
129
|
+
return {
|
|
130
|
+
...raw,
|
|
131
|
+
sourceUrl,
|
|
132
|
+
extractedAt: new Date().toISOString(),
|
|
133
|
+
};
|
|
79
134
|
}
|
|
@@ -1,9 +1,21 @@
|
|
|
1
|
+
import { assessContrast } from './contrast.js';
|
|
2
|
+
// Colours closer than this in RGB space read as the same surface.
|
|
3
|
+
const BACKGROUND_MATCH_DISTANCE = 40;
|
|
4
|
+
// A secondary must be visibly distinct from the primary, not a shade of it.
|
|
5
|
+
const SECONDARY_MIN_DISTANCE = 60;
|
|
1
6
|
function parseCssColor(css) {
|
|
2
7
|
const m = /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec(css);
|
|
3
8
|
if (!m)
|
|
4
9
|
return null;
|
|
5
10
|
return [parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10)];
|
|
6
11
|
}
|
|
12
|
+
function hexToRgb(hex) {
|
|
13
|
+
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
|
|
14
|
+
if (!m)
|
|
15
|
+
return null;
|
|
16
|
+
const n = parseInt(m[1], 16);
|
|
17
|
+
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
|
18
|
+
}
|
|
7
19
|
function rgbToHex(r, g, b) {
|
|
8
20
|
const ch = (v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0');
|
|
9
21
|
return `#${ch(r)}${ch(g)}${ch(b)}`;
|
|
@@ -11,10 +23,34 @@ function rgbToHex(r, g, b) {
|
|
|
11
23
|
function rgbRange(r, g, b) {
|
|
12
24
|
return Math.max(r, g, b) - Math.min(r, g, b);
|
|
13
25
|
}
|
|
26
|
+
function colorDistance(a, b) {
|
|
27
|
+
return Math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2);
|
|
28
|
+
}
|
|
14
29
|
function parsePx(value) {
|
|
15
30
|
const m = /^([\d.]+)px$/.exec(value.trim());
|
|
16
31
|
return m ? parseFloat(m[1]) : null;
|
|
17
32
|
}
|
|
33
|
+
function parseDurationMs(value) {
|
|
34
|
+
const m = /^([\d.]+)(ms|s)$/.exec(value.trim());
|
|
35
|
+
if (!m)
|
|
36
|
+
return null;
|
|
37
|
+
const n = parseFloat(m[1]);
|
|
38
|
+
return m[2] === 's' ? n * 1000 : n;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* A cubic-bezier whose control-point Y values escape [0,1] overshoots its target and
|
|
42
|
+
* springs back — the signature of a playful motion system rather than a functional one.
|
|
43
|
+
*/
|
|
44
|
+
function isOvershooting(easing) {
|
|
45
|
+
const m = /^cubic-bezier\(([^)]+)\)$/.exec(easing.trim());
|
|
46
|
+
if (!m)
|
|
47
|
+
return false;
|
|
48
|
+
const parts = m[1].split(',').map(p => parseFloat(p.trim()));
|
|
49
|
+
if (parts.length !== 4 || parts.some(Number.isNaN))
|
|
50
|
+
return false;
|
|
51
|
+
const [, y1, , y2] = parts;
|
|
52
|
+
return y1 < 0 || y1 > 1 || y2 < 0 || y2 > 1;
|
|
53
|
+
}
|
|
18
54
|
function median(values) {
|
|
19
55
|
if (values.length === 0)
|
|
20
56
|
return null;
|
|
@@ -24,15 +60,27 @@ function median(values) {
|
|
|
24
60
|
? (sorted[mid - 1] + sorted[mid]) / 2
|
|
25
61
|
: sorted[mid];
|
|
26
62
|
}
|
|
27
|
-
function
|
|
63
|
+
function isBackground(rgb, background) {
|
|
64
|
+
return background !== null && colorDistance(rgb, background) <= BACKGROUND_MATCH_DISTANCE;
|
|
65
|
+
}
|
|
66
|
+
function inferPrimaryColor(evidence, background, imageEvidence) {
|
|
28
67
|
if (imageEvidence && imageEvidence.dominantColors.length > 0) {
|
|
29
|
-
|
|
68
|
+
// dominantColors is frequency-sorted, so [0] is whatever covers the most pixels —
|
|
69
|
+
// on a typical page that is the background, not the brand colour.
|
|
70
|
+
for (const dc of imageEvidence.dominantColors) {
|
|
71
|
+
const rgb = hexToRgb(dc.hex);
|
|
72
|
+
if (rgb && !isBackground(rgb, background)) {
|
|
73
|
+
return { value: dc.hex, confidence: 'high' };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// Every cluster sits on the background: a monochrome page offers nothing to prefer.
|
|
77
|
+
return { value: imageEvidence.dominantColors[0].hex, confidence: 'medium' };
|
|
30
78
|
}
|
|
31
79
|
let bestHex = '';
|
|
32
80
|
let bestRange = -1;
|
|
33
81
|
for (const css of evidence.colors) {
|
|
34
82
|
const rgb = parseCssColor(css);
|
|
35
|
-
if (!rgb)
|
|
83
|
+
if (!rgb || isBackground(rgb, background))
|
|
36
84
|
continue;
|
|
37
85
|
const range = rgbRange(rgb[0], rgb[1], rgb[2]);
|
|
38
86
|
if (range > bestRange) {
|
|
@@ -45,6 +93,33 @@ function inferPrimaryColor(evidence, imageEvidence) {
|
|
|
45
93
|
}
|
|
46
94
|
return { value: '#3b82f6', confidence: 'low' };
|
|
47
95
|
}
|
|
96
|
+
function inferSecondaryColor(primaryHex, evidence, background, imageEvidence) {
|
|
97
|
+
const primary = hexToRgb(primaryHex);
|
|
98
|
+
const isCandidate = (rgb) => !isBackground(rgb, background) &&
|
|
99
|
+
(primary === null || colorDistance(rgb, primary) >= SECONDARY_MIN_DISTANCE);
|
|
100
|
+
if (imageEvidence) {
|
|
101
|
+
for (const dc of imageEvidence.dominantColors) {
|
|
102
|
+
const rgb = hexToRgb(dc.hex);
|
|
103
|
+
if (rgb && isCandidate(rgb))
|
|
104
|
+
return { value: dc.hex, confidence: 'high' };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
let bestHex = '';
|
|
108
|
+
let bestRange = -1;
|
|
109
|
+
for (const css of evidence.colors) {
|
|
110
|
+
const rgb = parseCssColor(css);
|
|
111
|
+
if (!rgb || !isCandidate(rgb))
|
|
112
|
+
continue;
|
|
113
|
+
const range = rgbRange(rgb[0], rgb[1], rgb[2]);
|
|
114
|
+
if (range > bestRange) {
|
|
115
|
+
bestRange = range;
|
|
116
|
+
bestHex = rgbToHex(rgb[0], rgb[1], rgb[2]);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (bestHex)
|
|
120
|
+
return { value: bestHex, confidence: 'medium' };
|
|
121
|
+
return { value: 'auto', confidence: 'low' };
|
|
122
|
+
}
|
|
48
123
|
function inferTypography(fontFamilies) {
|
|
49
124
|
const haystack = fontFamilies.join(' ').toLowerCase();
|
|
50
125
|
if (!haystack.trim())
|
|
@@ -59,6 +134,12 @@ function inferTypography(fontFamilies) {
|
|
|
59
134
|
[/merriweather|lora|ibm plex|source serif|humanist|charter|utopia/, 'humanist'],
|
|
60
135
|
[/helvetica|avenir|gill sans|calibri|myriad|aktiv|graphik|haas/, 'minimal'],
|
|
61
136
|
[/corporate|arial|verdana|trebuchet/, 'corporate'],
|
|
137
|
+
// Each pairing's own faces, per TYPOGRAPHY_FONT_FAMILIES in the token engine
|
|
138
|
+
// (components/shared/other-dimensions.js). Without these two rules 'startup' and
|
|
139
|
+
// 'creative' were unreachable — dead entries in a twelve-value contract enum.
|
|
140
|
+
// They sit above 'modern' so its broad system-font net cannot swallow them.
|
|
141
|
+
[/outfit/, 'startup'],
|
|
142
|
+
[/sora|rubik/, 'creative'],
|
|
62
143
|
[/inter|roboto|system-ui|segoe|sf pro|apple system|blinkmacsystem|-apple-system/, 'modern'],
|
|
63
144
|
];
|
|
64
145
|
for (const [pattern, preset] of rules) {
|
|
@@ -103,31 +184,57 @@ function inferDensity(spacings) {
|
|
|
103
184
|
return { value: 'comfortable', confidence: 'medium' };
|
|
104
185
|
return { value: 'spacious', confidence: 'medium' };
|
|
105
186
|
}
|
|
187
|
+
function inferMotion(durations, easings) {
|
|
188
|
+
const ms = durations
|
|
189
|
+
.map(parseDurationMs)
|
|
190
|
+
.filter((v) => v !== null && v > 0);
|
|
191
|
+
if (ms.length === 0) {
|
|
192
|
+
// Transitions declared only under :hover / :focus never surface in a base-state
|
|
193
|
+
// computed-style sample, so their absence is suggestive rather than conclusive.
|
|
194
|
+
return { value: 'instant', confidence: 'medium' };
|
|
195
|
+
}
|
|
196
|
+
if (easings.some(isOvershooting))
|
|
197
|
+
return { value: 'playful', confidence: 'high' };
|
|
198
|
+
const med = median(ms);
|
|
199
|
+
if (med < 150)
|
|
200
|
+
return { value: 'snappy', confidence: 'high' };
|
|
201
|
+
if (med <= 400)
|
|
202
|
+
return { value: 'smooth', confidence: 'high' };
|
|
203
|
+
return { value: 'playful', confidence: 'medium' };
|
|
204
|
+
}
|
|
106
205
|
export function inferTheme(evidence, imageEvidence) {
|
|
107
|
-
const
|
|
206
|
+
const background = evidence.backgroundColor ? parseCssColor(evidence.backgroundColor) : null;
|
|
207
|
+
const primary = inferPrimaryColor(evidence, background, imageEvidence);
|
|
208
|
+
const secondary = inferSecondaryColor(primary.value, evidence, background, imageEvidence);
|
|
108
209
|
const typography = inferTypography(evidence.fontFamilies);
|
|
109
210
|
const radius = inferBorderRadius(evidence.borderRadii);
|
|
110
211
|
const elevation = inferElevation(evidence.shadows);
|
|
111
212
|
const density = inferDensity(evidence.spacings);
|
|
213
|
+
const motion = inferMotion(evidence.motionDurations, evidence.motionEasings);
|
|
214
|
+
const primaryRgb = hexToRgb(primary.value);
|
|
215
|
+
const contrast = background && primaryRgb
|
|
216
|
+
? assessContrast(primaryRgb, background, rgbToHex(background[0], background[1], background[2]))
|
|
217
|
+
: null;
|
|
112
218
|
return {
|
|
113
219
|
theme: {
|
|
114
220
|
primaryColor: primary.value,
|
|
115
|
-
secondaryColor:
|
|
221
|
+
secondaryColor: secondary.value,
|
|
116
222
|
typography: typography.value,
|
|
117
223
|
density: density.value,
|
|
118
224
|
borderRadius: radius.value,
|
|
119
225
|
elevation: elevation.value,
|
|
120
|
-
motion:
|
|
226
|
+
motion: motion.value,
|
|
121
227
|
},
|
|
122
228
|
confidence: {
|
|
123
229
|
primaryColor: primary.confidence,
|
|
124
|
-
secondaryColor:
|
|
230
|
+
secondaryColor: secondary.confidence,
|
|
125
231
|
typography: typography.confidence,
|
|
126
232
|
density: density.confidence,
|
|
127
233
|
borderRadius: radius.confidence,
|
|
128
234
|
elevation: elevation.confidence,
|
|
129
|
-
motion:
|
|
235
|
+
motion: motion.confidence,
|
|
130
236
|
},
|
|
237
|
+
contrast,
|
|
131
238
|
sourceUrl: evidence.sourceUrl,
|
|
132
239
|
inferredAt: new Date().toISOString(),
|
|
133
240
|
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { readFileSync } 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
|
+
export const GET_COMPLETENESS_MAP_TOOL = {
|
|
7
|
+
name: 'get_completeness_map',
|
|
8
|
+
description: 'Returns the RunsNative component completeness map: which semantic UI slots have a real ' +
|
|
9
|
+
'run-* component vs raw-HTML fallback. Used by the emphasis allocation skill to know the ' +
|
|
10
|
+
'honest vocabulary boundary when assigning roles.',
|
|
11
|
+
inputSchema: {
|
|
12
|
+
type: 'object',
|
|
13
|
+
properties: {},
|
|
14
|
+
required: [],
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
export function handleGetCompletenessMap() {
|
|
18
|
+
// In dist/, __dirname resolves to dist/tools/; map is at ../../content/scf/.
|
|
19
|
+
const mapPath = resolve(__dirname, '../../content/scf/completeness-map.json');
|
|
20
|
+
const raw = readFileSync(mapPath, 'utf8');
|
|
21
|
+
return {
|
|
22
|
+
content: [{ type: 'text', text: raw }],
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Mirrors DEFAULT_EMPHASIS_SCALE from
|
|
2
|
+
// packages/engine/src/mukadra/tools/pm_agent/emphasis_policy_schema.py.
|
|
3
|
+
// Vocabulary is methodology-fixed; weights are not brand-configurable at this layer.
|
|
4
|
+
const EMPHASIS_SCALE = [
|
|
5
|
+
{
|
|
6
|
+
treatment: 'focal',
|
|
7
|
+
loudness_weight: 10,
|
|
8
|
+
description: 'Loudest element in scope — primary CTA, hero heading. Exactly one per scope.',
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
treatment: 'accent',
|
|
12
|
+
loudness_weight: 5,
|
|
13
|
+
description: 'Secondary highlight — section heading, secondary CTA, badge.',
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
treatment: 'link',
|
|
17
|
+
loudness_weight: 2,
|
|
18
|
+
description: 'Navigation and inline links. Multiple allowed.',
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
treatment: 'supporting',
|
|
22
|
+
loudness_weight: 1,
|
|
23
|
+
description: 'Body copy, labels, captions. Neutral presence.',
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
treatment: 'ambient',
|
|
27
|
+
loudness_weight: 0,
|
|
28
|
+
description: 'Decorative or invisible to budget — dividers, icons used as decoration.',
|
|
29
|
+
},
|
|
30
|
+
];
|
|
31
|
+
export const GET_EMPHASIS_SCALE_TOOL = {
|
|
32
|
+
name: 'get_emphasis_scale',
|
|
33
|
+
description: 'Returns the canonical SCF emphasis treatment vocabulary: the five treatment levels ' +
|
|
34
|
+
'(focal → ambient), their loudness weights, and plain-language descriptions. ' +
|
|
35
|
+
'Used by the emphasis role-allocation skill to know what treatments exist and what focal costs.',
|
|
36
|
+
inputSchema: {
|
|
37
|
+
type: 'object',
|
|
38
|
+
properties: {},
|
|
39
|
+
required: [],
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
export function handleGetEmphasisScale() {
|
|
43
|
+
return {
|
|
44
|
+
content: [
|
|
45
|
+
{
|
|
46
|
+
type: 'text',
|
|
47
|
+
text: JSON.stringify({
|
|
48
|
+
treatments: EMPHASIS_SCALE,
|
|
49
|
+
methodology_note: 'Loudness weights are additive across a scope. The ceiling (brand_disposition × page_type) ' +
|
|
50
|
+
'constrains weighted_spend = sum(weight × count). Exactly one focal per scope is the marketing rule.',
|
|
51
|
+
}, null, 2),
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
import { getWorkerApi, getLinkedPairId } from '../bridge-client.js';
|
|
3
|
+
const CROP_PREFIX = 'data:image/png;base64,';
|
|
4
|
+
export const GET_MARKER_CAPTURE_TOOL = {
|
|
5
|
+
name: 'get_marker_capture',
|
|
6
|
+
description: 'Fetch pending marker captures from the linked runsnative.org tab — regions the user circled ' +
|
|
7
|
+
'with the marker overlay. Use when the user says they marked, circled, or pointed at something ' +
|
|
8
|
+
'on the site, or asks "can you see what I selected?". Each capture carries the resolved ' +
|
|
9
|
+
'component-instance address (usable with set_instance_variant), its render inputs, an optional ' +
|
|
10
|
+
'user note, and the pixel crop as an image so you see exactly what they saw. Single delivery: a ' +
|
|
11
|
+
'capture is returned once. Requires an active linked session (call link_session first).',
|
|
12
|
+
inputSchema: {
|
|
13
|
+
type: 'object',
|
|
14
|
+
properties: {},
|
|
15
|
+
},
|
|
16
|
+
// LSP §5.1 (linked-session-protocol-v0.1, mukadra repo) — session-targeting,
|
|
17
|
+
// but the READ leg of the bridge: it drains the session's capture queue
|
|
18
|
+
// rather than enacting a command, so there is no allow-list `enactment`
|
|
19
|
+
// (the field is optional per §5.1).
|
|
20
|
+
_meta: {
|
|
21
|
+
lsp: {
|
|
22
|
+
target: 'session',
|
|
23
|
+
tier: 'reversible',
|
|
24
|
+
consent: 'implicit',
|
|
25
|
+
entitlement: 'free',
|
|
26
|
+
visibility: 'advertised',
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
export async function handleGetMarkerCapture(_args) {
|
|
31
|
+
const pairId = getLinkedPairId();
|
|
32
|
+
if (!pairId) {
|
|
33
|
+
throw new McpError(ErrorCode.InvalidParams, 'No linked session. Call link_session with the pairing code first, or open runsnative.org and start the pairing flow.');
|
|
34
|
+
}
|
|
35
|
+
const res = await getWorkerApi(`/session/${pairId}/captures`);
|
|
36
|
+
if (res.status === 404) {
|
|
37
|
+
throw new McpError(ErrorCode.InvalidParams, 'Linked session not found or expired. Re-link with link_session.');
|
|
38
|
+
}
|
|
39
|
+
if (res.status === 410) {
|
|
40
|
+
throw new McpError(ErrorCode.InvalidParams, 'Linked session revoked or expired. Re-link with link_session.');
|
|
41
|
+
}
|
|
42
|
+
if (res.status === 409) {
|
|
43
|
+
throw new McpError(ErrorCode.InvalidParams, 'No live tab detected. Open runsnative.org, ensure the tab is active, then retry.');
|
|
44
|
+
}
|
|
45
|
+
if (!res.ok) {
|
|
46
|
+
const body = await res.text().catch(() => '');
|
|
47
|
+
throw new McpError(ErrorCode.InternalError, `Worker error ${res.status}: ${body}`);
|
|
48
|
+
}
|
|
49
|
+
const data = await res.json();
|
|
50
|
+
const captures = data.captures ?? [];
|
|
51
|
+
if (captures.length === 0) {
|
|
52
|
+
return {
|
|
53
|
+
content: [
|
|
54
|
+
{
|
|
55
|
+
type: 'text',
|
|
56
|
+
text: 'No pending marker captures. Ask the user to arm the marker overlay and circle a region on the linked tab.',
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
const content = [];
|
|
62
|
+
for (const cap of captures) {
|
|
63
|
+
content.push({
|
|
64
|
+
type: 'text',
|
|
65
|
+
text: JSON.stringify({
|
|
66
|
+
capture_id: cap.capture_id,
|
|
67
|
+
address: cap.address,
|
|
68
|
+
inputs: cap.inputs,
|
|
69
|
+
note: cap.note,
|
|
70
|
+
created_at: cap.created_at,
|
|
71
|
+
}, null, 2),
|
|
72
|
+
});
|
|
73
|
+
// The pixel crop is co-equal with the structural address (design doc
|
|
74
|
+
// §3.4) — deliver it as an image block so the agent SEES the region,
|
|
75
|
+
// not a reference to it.
|
|
76
|
+
if (cap.crop && cap.crop.startsWith(CROP_PREFIX)) {
|
|
77
|
+
content.push({
|
|
78
|
+
type: 'image',
|
|
79
|
+
data: cap.crop.slice(CROP_PREFIX.length),
|
|
80
|
+
mimeType: 'image/png',
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (data.truncated) {
|
|
85
|
+
content.push({
|
|
86
|
+
type: 'text',
|
|
87
|
+
text: 'More pending captures remain on the worker — call get_marker_capture again to fetch them.',
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return { content };
|
|
91
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
import { postWorkerApi, setLinkedPairId } from '../bridge-client.js';
|
|
3
|
+
export const LINK_SESSION_TOOL = {
|
|
4
|
+
name: 'link_session',
|
|
5
|
+
description: 'Redeem a pairing code to link this MCP session to an open runsnative.org tab. ' +
|
|
6
|
+
'The user generates the code in their browser; pass it here to establish the link. ' +
|
|
7
|
+
'Required before set_brand or navigate will work.',
|
|
8
|
+
inputSchema: {
|
|
9
|
+
type: 'object',
|
|
10
|
+
properties: {
|
|
11
|
+
code: { type: 'string', description: 'Six-character pairing code shown in the browser.' },
|
|
12
|
+
},
|
|
13
|
+
required: ['code'],
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
export async function handleLinkSession(args) {
|
|
17
|
+
const code = args['code'];
|
|
18
|
+
if (typeof code !== 'string' || !code.trim()) {
|
|
19
|
+
throw new McpError(ErrorCode.InvalidParams, 'code must be a non-empty string');
|
|
20
|
+
}
|
|
21
|
+
const res = await postWorkerApi('/pair/redeem', { code: code.trim() });
|
|
22
|
+
if (res.status === 403) {
|
|
23
|
+
throw new McpError(ErrorCode.InvalidParams, 'Pairing code belongs to a different tenant — cannot link.');
|
|
24
|
+
}
|
|
25
|
+
if (res.status === 404) {
|
|
26
|
+
throw new McpError(ErrorCode.InvalidParams, 'Pairing code not found or already expired. Ask the user to generate a fresh code.');
|
|
27
|
+
}
|
|
28
|
+
if (!res.ok) {
|
|
29
|
+
const body = await res.text().catch(() => '');
|
|
30
|
+
throw new McpError(ErrorCode.InternalError, `Worker error ${res.status}: ${body}`);
|
|
31
|
+
}
|
|
32
|
+
const data = await res.json();
|
|
33
|
+
setLinkedPairId(data.pair_id);
|
|
34
|
+
return { content: [{ type: 'text', text: `Session linked. pair_id: ${data.pair_id}` }] };
|
|
35
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
import { postWorkerApi, getLinkedPairId } from '../bridge-client.js';
|
|
3
|
+
export const NAVIGATE_TOOL = {
|
|
4
|
+
name: 'navigate',
|
|
5
|
+
description: "Send the user's linked runsnative.org tab to a different page. Use when the user asks to see, open, or go to " +
|
|
6
|
+
'something on the site — a component page, a docs section, the gallery — so they watch their own tab move ' +
|
|
7
|
+
'instead of following a pasted link. The command is enqueued and the tab navigates within one poll interval. ' +
|
|
8
|
+
'Reversible — the user can navigate back. Requires an active linked session (call link_session first). ' +
|
|
9
|
+
'Slash shim: /rn-go <path>.',
|
|
10
|
+
inputSchema: {
|
|
11
|
+
type: 'object',
|
|
12
|
+
properties: {
|
|
13
|
+
path: { type: 'string', description: 'Absolute path to navigate to, e.g. "/components/button".' },
|
|
14
|
+
},
|
|
15
|
+
required: ['path'],
|
|
16
|
+
},
|
|
17
|
+
// LSP §5.1 (linked-session-protocol-v0.1, mukadra repo) — marks this as a
|
|
18
|
+
// session-targeting capability. `enactment` names the worker allow-list
|
|
19
|
+
// entry this tool submits (JUNE-626).
|
|
20
|
+
_meta: {
|
|
21
|
+
lsp: {
|
|
22
|
+
target: 'session',
|
|
23
|
+
tier: 'reversible',
|
|
24
|
+
consent: 'implicit',
|
|
25
|
+
entitlement: 'free',
|
|
26
|
+
visibility: 'advertised',
|
|
27
|
+
enactment: 'navigation.navigate',
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
export async function handleNavigate(args) {
|
|
32
|
+
const path = args['path'];
|
|
33
|
+
if (typeof path !== 'string' || !path.trim()) {
|
|
34
|
+
throw new McpError(ErrorCode.InvalidParams, 'path must be a non-empty string');
|
|
35
|
+
}
|
|
36
|
+
const pairId = getLinkedPairId();
|
|
37
|
+
if (!pairId) {
|
|
38
|
+
throw new McpError(ErrorCode.InvalidParams, 'No linked session. Call link_session with the pairing code first, or open runsnative.org and start the pairing flow.');
|
|
39
|
+
}
|
|
40
|
+
const res = await postWorkerApi('/tenant/command', {
|
|
41
|
+
pair_id: pairId,
|
|
42
|
+
capability: 'navigation',
|
|
43
|
+
method: 'navigate',
|
|
44
|
+
args: [path.trim()],
|
|
45
|
+
});
|
|
46
|
+
if (res.status === 404) {
|
|
47
|
+
throw new McpError(ErrorCode.InvalidParams, 'Linked session not found or expired. Re-link with link_session.');
|
|
48
|
+
}
|
|
49
|
+
if (res.status === 422) {
|
|
50
|
+
const body = await res.json().catch(() => ({}));
|
|
51
|
+
throw new McpError(ErrorCode.InvalidParams, body.error ?? 'Command rejected by allow-list.');
|
|
52
|
+
}
|
|
53
|
+
if (res.status === 409) {
|
|
54
|
+
throw new McpError(ErrorCode.InvalidParams, 'No live tab detected. Open runsnative.org, ensure the tab is active, then retry.');
|
|
55
|
+
}
|
|
56
|
+
if (!res.ok) {
|
|
57
|
+
const body = await res.text().catch(() => '');
|
|
58
|
+
throw new McpError(ErrorCode.InternalError, `Worker error ${res.status}: ${body}`);
|
|
59
|
+
}
|
|
60
|
+
return { content: [{ type: 'text', text: `Navigate command enqueued: ${path.trim()}. The tab will navigate within one poll interval.` }] };
|
|
61
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
import { postWorkerApi, getLinkedPairId } from '../bridge-client.js';
|
|
3
|
+
/** Mirrors the `variant` enum in `run-button.js` and the worker allow-list. */
|
|
4
|
+
const RUN_BUTTON_VARIANTS = ['primary', 'secondary', 'outline', 'ghost', 'danger', 'link'];
|
|
5
|
+
export const SET_INSTANCE_VARIANT_TOOL = {
|
|
6
|
+
name: 'set_instance_variant',
|
|
7
|
+
description: 'Restyle ONE specific run-button on the linked runsnative.org tab — the instance the user circled with the ' +
|
|
8
|
+
'marker overlay — leaving every other element untouched. Use after get_marker_capture hands you an ' +
|
|
9
|
+
'address.instanceId and the user wants that exact button tried in a different variant. The command is enqueued ' +
|
|
10
|
+
'and the tab applies it within one poll interval, if the instance is still on the page. Reversible — set it ' +
|
|
11
|
+
'back at any time. Requires an active linked session (call link_session first). Slash shim: /rn-set-variant.',
|
|
12
|
+
inputSchema: {
|
|
13
|
+
type: 'object',
|
|
14
|
+
properties: {
|
|
15
|
+
instance_id: {
|
|
16
|
+
type: 'string',
|
|
17
|
+
description: 'Instance handle from a marker-capture bundle (address.instanceId).',
|
|
18
|
+
},
|
|
19
|
+
variant: {
|
|
20
|
+
type: 'string',
|
|
21
|
+
description: `One of: ${RUN_BUTTON_VARIANTS.join(', ')}.`,
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
required: ['instance_id', 'variant'],
|
|
25
|
+
},
|
|
26
|
+
// LSP §5.1 (linked-session-protocol-v0.1, mukadra repo) — marks this as a
|
|
27
|
+
// session-targeting capability. `enactment` names the worker allow-list
|
|
28
|
+
// entry this tool submits (JUNE-626).
|
|
29
|
+
_meta: {
|
|
30
|
+
lsp: {
|
|
31
|
+
target: 'session',
|
|
32
|
+
tier: 'reversible',
|
|
33
|
+
consent: 'implicit',
|
|
34
|
+
entitlement: 'free',
|
|
35
|
+
visibility: 'advertised',
|
|
36
|
+
enactment: 'instance.setVariant',
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
export async function handleSetInstanceVariant(args) {
|
|
41
|
+
const instanceId = args['instance_id'];
|
|
42
|
+
if (typeof instanceId !== 'string' || !instanceId.trim()) {
|
|
43
|
+
throw new McpError(ErrorCode.InvalidParams, 'instance_id must be a non-empty string');
|
|
44
|
+
}
|
|
45
|
+
const variant = args['variant'];
|
|
46
|
+
if (typeof variant !== 'string' || !RUN_BUTTON_VARIANTS.includes(variant.trim())) {
|
|
47
|
+
throw new McpError(ErrorCode.InvalidParams, `variant must be one of: ${RUN_BUTTON_VARIANTS.join(', ')}`);
|
|
48
|
+
}
|
|
49
|
+
const pairId = getLinkedPairId();
|
|
50
|
+
if (!pairId) {
|
|
51
|
+
throw new McpError(ErrorCode.InvalidParams, 'No linked session. Call link_session with the pairing code first, or open runsnative.org and start the pairing flow.');
|
|
52
|
+
}
|
|
53
|
+
const res = await postWorkerApi('/tenant/command', {
|
|
54
|
+
pair_id: pairId,
|
|
55
|
+
capability: 'instance',
|
|
56
|
+
method: 'setVariant',
|
|
57
|
+
args: [variant.trim()],
|
|
58
|
+
instance_id: instanceId.trim(),
|
|
59
|
+
});
|
|
60
|
+
if (res.status === 404) {
|
|
61
|
+
throw new McpError(ErrorCode.InvalidParams, 'Linked session not found or expired. Re-link with link_session.');
|
|
62
|
+
}
|
|
63
|
+
if (res.status === 422) {
|
|
64
|
+
const body = await res.json().catch(() => ({}));
|
|
65
|
+
throw new McpError(ErrorCode.InvalidParams, body.error ?? 'Command rejected by allow-list.');
|
|
66
|
+
}
|
|
67
|
+
if (res.status === 409) {
|
|
68
|
+
throw new McpError(ErrorCode.InvalidParams, 'No live tab detected. Open runsnative.org, ensure the tab is active, then retry.');
|
|
69
|
+
}
|
|
70
|
+
if (!res.ok) {
|
|
71
|
+
const body = await res.text().catch(() => '');
|
|
72
|
+
throw new McpError(ErrorCode.InternalError, `Worker error ${res.status}: ${body}`);
|
|
73
|
+
}
|
|
74
|
+
// A handle that no longer resolves in the tab is dropped client-side, by design —
|
|
75
|
+
// the worker cannot resolve DOM, so enqueue success is not delivery confirmation.
|
|
76
|
+
return {
|
|
77
|
+
content: [
|
|
78
|
+
{
|
|
79
|
+
type: 'text',
|
|
80
|
+
text: `Variant command enqueued: ${variant.trim()} → instance ${instanceId.trim()}. ` +
|
|
81
|
+
`The tab will apply it within one poll interval if that instance is still on the page.`,
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
import { postWorkerApi, getLinkedPairId } from '../bridge-client.js';
|
|
3
|
+
export const SET_THEME_TOOL = {
|
|
4
|
+
name: 'set_theme',
|
|
5
|
+
description: "Restyle the user's linked runsnative.org tab with a named theme — colors, surfaces, and typography change live " +
|
|
6
|
+
'in their browser. Use when the user asks to change how the site looks, try a different brand feel, or compare ' +
|
|
7
|
+
'palettes. The command is enqueued and the tab applies it within one poll interval; nothing is saved — fully ' +
|
|
8
|
+
'reversible, switch again or back at any time. Requires an active linked session (call link_session first). ' +
|
|
9
|
+
'Slash shim: /rn-theme <theme>.',
|
|
10
|
+
inputSchema: {
|
|
11
|
+
type: 'object',
|
|
12
|
+
properties: {
|
|
13
|
+
theme: { type: 'string', description: 'Theme identifier, e.g. "ocean-depths" or "kehribar".' },
|
|
14
|
+
},
|
|
15
|
+
required: ['theme'],
|
|
16
|
+
},
|
|
17
|
+
// LSP §5.1 (linked-session-protocol-v0.1, mukadra repo) — marks this as a
|
|
18
|
+
// session-targeting capability. `enactment` names the worker allow-list
|
|
19
|
+
// entry this tool submits (JUNE-626).
|
|
20
|
+
_meta: {
|
|
21
|
+
lsp: {
|
|
22
|
+
target: 'session',
|
|
23
|
+
tier: 'reversible',
|
|
24
|
+
consent: 'implicit',
|
|
25
|
+
entitlement: 'free',
|
|
26
|
+
visibility: 'advertised',
|
|
27
|
+
enactment: 'theme.setTheme',
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
export async function handleSetTheme(args) {
|
|
32
|
+
const theme = args['theme'];
|
|
33
|
+
if (typeof theme !== 'string' || !theme.trim()) {
|
|
34
|
+
throw new McpError(ErrorCode.InvalidParams, 'theme must be a non-empty string');
|
|
35
|
+
}
|
|
36
|
+
const pairId = getLinkedPairId();
|
|
37
|
+
if (!pairId) {
|
|
38
|
+
throw new McpError(ErrorCode.InvalidParams, 'No linked session. Call link_session with the pairing code first, or open runsnative.org and start the pairing flow.');
|
|
39
|
+
}
|
|
40
|
+
const res = await postWorkerApi('/tenant/command', {
|
|
41
|
+
pair_id: pairId,
|
|
42
|
+
capability: 'theme',
|
|
43
|
+
method: 'setTheme',
|
|
44
|
+
args: [theme.trim()],
|
|
45
|
+
});
|
|
46
|
+
if (res.status === 404) {
|
|
47
|
+
throw new McpError(ErrorCode.InvalidParams, 'Linked session not found or expired. Re-link with link_session.');
|
|
48
|
+
}
|
|
49
|
+
if (res.status === 422) {
|
|
50
|
+
const body = await res.json().catch(() => ({}));
|
|
51
|
+
throw new McpError(ErrorCode.InvalidParams, body.error ?? 'Command rejected by allow-list.');
|
|
52
|
+
}
|
|
53
|
+
if (res.status === 409) {
|
|
54
|
+
throw new McpError(ErrorCode.InvalidParams, 'No live tab detected. Open runsnative.org, ensure the tab is active, then retry.');
|
|
55
|
+
}
|
|
56
|
+
if (!res.ok) {
|
|
57
|
+
const body = await res.text().catch(() => '');
|
|
58
|
+
throw new McpError(ErrorCode.InternalError, `Worker error ${res.status}: ${body}`);
|
|
59
|
+
}
|
|
60
|
+
return { content: [{ type: 'text', text: `Theme command enqueued: ${theme.trim()}. The tab will apply it within one poll interval.` }] };
|
|
61
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@runsnative/mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"dist/"
|
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
"start": "node dist/index.js",
|
|
21
21
|
"http-server": "node dist/http-server.js",
|
|
22
22
|
"validate": "node dist/test/validate.js",
|
|
23
|
-
"test": "vitest run"
|
|
23
|
+
"test": "vitest run",
|
|
24
|
+
"prepublishOnly": "npm run build"
|
|
24
25
|
},
|
|
25
26
|
"dependencies": {
|
|
26
27
|
"@modelcontextprotocol/sdk": "^1.0.0"
|