@runsnative/mcp-server 0.4.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.
@@ -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':
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runsnative/mcp-server",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist/"