@runsnative/mcp-server 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,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
CHANGED
|
@@ -18,6 +18,7 @@ import { GET_EMPHASIS_SCALE_TOOL, handleGetEmphasisScale } from './tools/get-emp
|
|
|
18
18
|
import { GET_COMPLETENESS_MAP_TOOL, handleGetCompletenessMap } from './tools/get-completeness-map.js';
|
|
19
19
|
import { LINK_SESSION_TOOL, handleLinkSession } from './tools/link-session.js';
|
|
20
20
|
import { SET_THEME_TOOL, handleSetTheme } from './tools/set-theme.js';
|
|
21
|
+
import { APPLY_INFERRED_THEME_TOOL, handleApplyInferredTheme } from './tools/apply-inferred-theme.js';
|
|
21
22
|
import { NAVIGATE_TOOL, handleNavigate } from './tools/navigate.js';
|
|
22
23
|
import { SET_INSTANCE_VARIANT_TOOL, handleSetInstanceVariant } from './tools/set-instance-variant.js';
|
|
23
24
|
import { GET_MARKER_CAPTURE_TOOL, handleGetMarkerCapture } from './tools/get-marker-capture.js';
|
|
@@ -60,6 +61,7 @@ export async function createRunsnativeServer() {
|
|
|
60
61
|
GET_COMPLETENESS_MAP_TOOL,
|
|
61
62
|
LINK_SESSION_TOOL,
|
|
62
63
|
SET_THEME_TOOL,
|
|
64
|
+
APPLY_INFERRED_THEME_TOOL,
|
|
63
65
|
NAVIGATE_TOOL,
|
|
64
66
|
SET_INSTANCE_VARIANT_TOOL,
|
|
65
67
|
GET_MARKER_CAPTURE_TOOL,
|
|
@@ -99,6 +101,8 @@ export async function createRunsnativeServer() {
|
|
|
99
101
|
return handleLinkSession(request.params.arguments ?? {});
|
|
100
102
|
case 'set_theme':
|
|
101
103
|
return handleSetTheme(request.params.arguments ?? {});
|
|
104
|
+
case 'apply_inferred_theme':
|
|
105
|
+
return handleApplyInferredTheme(request.params.arguments ?? {});
|
|
102
106
|
case 'navigate':
|
|
103
107
|
return handleNavigate(request.params.arguments ?? {});
|
|
104
108
|
case 'set_instance_variant':
|
package/dist/test/validate.js
CHANGED
|
@@ -136,7 +136,7 @@ console.log('\nget_foundation');
|
|
|
136
136
|
console.log('\nlistFoundations');
|
|
137
137
|
{
|
|
138
138
|
const foundations = await listFoundations();
|
|
139
|
-
check('returns
|
|
139
|
+
check('returns 8 foundations', () => assert(foundations.length === 8, `expected 8 foundations, got ${foundations.length}`));
|
|
140
140
|
check('includes color', () => assert(foundations.some((f) => f.foundation === 'color'), 'missing color'));
|
|
141
141
|
check('includes typography', () => assert(foundations.some((f) => f.foundation === 'typography'), 'missing typography'));
|
|
142
142
|
check('each foundation has title and description', () => {
|
|
@@ -214,7 +214,7 @@ console.log('\nlist_exercises');
|
|
|
214
214
|
{
|
|
215
215
|
const ready = await listExerciseMeta(false);
|
|
216
216
|
const all = await listExerciseMeta(true);
|
|
217
|
-
check('returns all
|
|
217
|
+
check('returns all four ready exercises', () => assert(ready.length === 4, `expected 4 ready exercises, got ${ready.length}`));
|
|
218
218
|
check('each exercise has required fields', () => {
|
|
219
219
|
for (const ex of ready) {
|
|
220
220
|
assert(typeof ex.name === 'string' && ex.name.length > 0, `exercise missing name`);
|
|
@@ -284,6 +284,40 @@ console.log('\nget_step');
|
|
|
284
284
|
});
|
|
285
285
|
}
|
|
286
286
|
// ---------------------------------------------------------------------------
|
|
287
|
+
// Coached exercise slice — live-session binding (JUNE-625)
|
|
288
|
+
//
|
|
289
|
+
// migrate-to-runsnative step 5 gained a "Coached Mode" section that drives
|
|
290
|
+
// the bridge's set_theme on the learner's own linked session; step 7
|
|
291
|
+
// verifies the result via get_marker_capture. Regression guard: the coached
|
|
292
|
+
// content must exist, name the correct bridge tools, and the exercise's
|
|
293
|
+
// step count must have grown to 7 without disturbing steps 1-6.
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
console.log('\ncoached exercise slice (JUNE-625)');
|
|
296
|
+
{
|
|
297
|
+
const detail = await getExerciseDetail('migrate-to-runsnative');
|
|
298
|
+
check('migrate-to-runsnative now has 7 steps', () => assert(detail.steps.length === 7, `expected 7 steps, got ${detail.steps.length}`));
|
|
299
|
+
const step5 = await getExerciseStep('migrate-to-runsnative', 5);
|
|
300
|
+
check('step 5 documents Coached Mode', () => assert(step5.content.includes('Coached Mode'), 'missing Coached Mode section'));
|
|
301
|
+
check('step 5 names set_theme', () => assert(step5.content.includes('set_theme'), 'missing set_theme reference'));
|
|
302
|
+
check('step 5 links to /ai/linked-session', () => assert(step5.content.includes('/ai/linked-session'), 'missing linked-session doc link'));
|
|
303
|
+
const step7 = await getExerciseStep('migrate-to-runsnative', 7);
|
|
304
|
+
check('step 7 returned with content', () => {
|
|
305
|
+
assert(step7.step === 7, `expected step 7, got ${step7.step}`);
|
|
306
|
+
assert(step7.title === 'Prove the live change (if linked)', `expected title match, got ${step7.title}`);
|
|
307
|
+
});
|
|
308
|
+
check('step 7 names get_marker_capture', () => assert(step7.content.includes('get_marker_capture'), 'missing get_marker_capture reference'));
|
|
309
|
+
check('step 7 is opt-in (skip guidance present)', () => assert(/skip/i.test(step7.content), 'missing skip-if-not-linked guidance'));
|
|
310
|
+
check('step 8 out-of-range throws RangeError', async () => {
|
|
311
|
+
try {
|
|
312
|
+
await getExerciseStep('migrate-to-runsnative', 8);
|
|
313
|
+
throw new Error('should have thrown');
|
|
314
|
+
}
|
|
315
|
+
catch (err) {
|
|
316
|
+
assert(err instanceof RangeError, `expected RangeError, got ${err.constructor.name}`);
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
287
321
|
// Exercise ↔ live-site wiring (RUN-354)
|
|
288
322
|
//
|
|
289
323
|
// Regression guard for the RUN-326 defect class: exercises that referenced
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
import { postWorkerApi, getLinkedPairId } from '../bridge-client.js';
|
|
3
|
+
export const APPLY_INFERRED_THEME_TOOL = {
|
|
4
|
+
name: 'apply_inferred_theme',
|
|
5
|
+
description: "Turn a written brand brief into a bespoke theme and apply it live to the user's linked runsnative.org tab. " +
|
|
6
|
+
'Describe the brand in plain language — mood, industry, color feelings ("warm terracotta ceramics studio", ' +
|
|
7
|
+
'"midnight fintech, electric green accents") — and the RunsNative inference engine derives a full themed token ' +
|
|
8
|
+
'profile server-side, saves it to your theme library, and restyles the tab within one poll interval. This is a ' +
|
|
9
|
+
'paid capability: without an active grant the call returns an upgrade path rather than a theme. Fully ' +
|
|
10
|
+
'reversible — switch back with set_theme at any time. Requires an active linked session (call link_session first).',
|
|
11
|
+
inputSchema: {
|
|
12
|
+
type: 'object',
|
|
13
|
+
properties: {
|
|
14
|
+
brief: {
|
|
15
|
+
type: 'string',
|
|
16
|
+
description: 'The brand brief: mood, industry, audience, and color language, in plain prose (up to 4000 characters).',
|
|
17
|
+
},
|
|
18
|
+
name: { type: 'string', description: 'Optional name for the saved theme; inferred from the brief when omitted.' },
|
|
19
|
+
},
|
|
20
|
+
required: ['brief'],
|
|
21
|
+
},
|
|
22
|
+
// LSP §5.1 — session-targeting; the first entitlement:'paid' capability on
|
|
23
|
+
// the bridge (JUNE-650). `enactment` names the worker allow-list entry the
|
|
24
|
+
// command leg submits after the paid inference leg succeeds.
|
|
25
|
+
_meta: {
|
|
26
|
+
lsp: {
|
|
27
|
+
target: 'session',
|
|
28
|
+
tier: 'reversible',
|
|
29
|
+
consent: 'implicit',
|
|
30
|
+
entitlement: 'paid',
|
|
31
|
+
visibility: 'advertised',
|
|
32
|
+
enactment: 'theme.applyInferredTheme',
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
export async function handleApplyInferredTheme(args) {
|
|
37
|
+
const brief = args['brief'];
|
|
38
|
+
if (typeof brief !== 'string' || !brief.trim()) {
|
|
39
|
+
throw new McpError(ErrorCode.InvalidParams, 'brief must be a non-empty brand-brief string');
|
|
40
|
+
}
|
|
41
|
+
const name = typeof args['name'] === 'string' && args['name'].trim() ? args['name'].trim() : undefined;
|
|
42
|
+
const pairId = getLinkedPairId();
|
|
43
|
+
if (!pairId) {
|
|
44
|
+
throw new McpError(ErrorCode.InvalidParams, 'No linked session. Call link_session with the pairing code first, or open runsnative.org and start the pairing flow.');
|
|
45
|
+
}
|
|
46
|
+
// Leg 1 — paid inference (server-side; the reasoning never reaches this client).
|
|
47
|
+
const inferRes = await postWorkerApi('/tenant/theme/infer', { brief: brief.trim(), ...(name ? { name } : {}) });
|
|
48
|
+
if (inferRes.status === 402) {
|
|
49
|
+
const body = await inferRes.json().catch(() => ({}));
|
|
50
|
+
// §8.2: the two denial reasons are DIFFERENT conversion paths — surface
|
|
51
|
+
// them distinctly so the calling agent can relay the right next step.
|
|
52
|
+
if (body.reason === 'renewal_required') {
|
|
53
|
+
throw new McpError(ErrorCode.InvalidParams, `Your grant for inferred themes has lapsed — renew to keep using it${body.payment_url ? `: ${body.payment_url}` : '.'}`);
|
|
54
|
+
}
|
|
55
|
+
throw new McpError(ErrorCode.InvalidParams, `Inferred themes are a paid capability you don't hold yet — upgrade to unlock it${body.payment_url ? `: ${body.payment_url}` : '.'}`);
|
|
56
|
+
}
|
|
57
|
+
if (inferRes.status === 400) {
|
|
58
|
+
const body = await inferRes.json().catch(() => ({}));
|
|
59
|
+
throw new McpError(ErrorCode.InvalidParams, body.error ?? 'Invalid brief.');
|
|
60
|
+
}
|
|
61
|
+
if (!inferRes.ok) {
|
|
62
|
+
const body = await inferRes.text().catch(() => '');
|
|
63
|
+
throw new McpError(ErrorCode.InternalError, `Theme inference failed (${inferRes.status}): ${body}`);
|
|
64
|
+
}
|
|
65
|
+
const inferred = await inferRes.json();
|
|
66
|
+
// Leg 2 — free-class application via the command bridge.
|
|
67
|
+
const cmdRes = await postWorkerApi('/tenant/command', {
|
|
68
|
+
pair_id: pairId,
|
|
69
|
+
capability: 'theme',
|
|
70
|
+
method: 'applyInferredTheme',
|
|
71
|
+
args: [`custom:${inferred.slug}`],
|
|
72
|
+
});
|
|
73
|
+
if (cmdRes.status === 404) {
|
|
74
|
+
throw new McpError(ErrorCode.InvalidParams, `Theme "${inferred.name ?? inferred.slug}" was saved, but the linked session was not found or expired. Re-link with link_session, then apply it with set_theme("custom:${inferred.slug}").`);
|
|
75
|
+
}
|
|
76
|
+
if (cmdRes.status === 409) {
|
|
77
|
+
throw new McpError(ErrorCode.InvalidParams, `Theme "${inferred.name ?? inferred.slug}" was saved, but no live tab was detected. Open runsnative.org, then apply it with set_theme("custom:${inferred.slug}").`);
|
|
78
|
+
}
|
|
79
|
+
if (!cmdRes.ok) {
|
|
80
|
+
const body = await cmdRes.text().catch(() => '');
|
|
81
|
+
throw new McpError(ErrorCode.InternalError, `Theme "${inferred.name ?? inferred.slug}" was saved, but applying it failed (${cmdRes.status}): ${body}`);
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
content: [{
|
|
85
|
+
type: 'text',
|
|
86
|
+
text: `Inferred theme "${inferred.name ?? inferred.slug}" (seed: ${inferred.seed_theme_id ?? 'n/a'}, slug: ${inferred.slug}) saved and applied — the tab restyles within one poll interval. Reverse any time with set_theme.`,
|
|
87
|
+
}],
|
|
88
|
+
};
|
|
89
|
+
}
|