@runsnative/mcp-server 0.8.1 → 0.9.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 CHANGED
@@ -75,6 +75,8 @@ Paste that URL into Claude's "Add custom skill" dialog, or add it to your agent'
75
75
  | `link_session` | Pair the agent with a logged-in runsnative.org tab (consent-gated) |
76
76
  | `navigate` / `set_theme` | Drive the linked tab: change routes, switch themes |
77
77
  | `set_instance_variant` | Change a component instance in the linked tab by marker-capture handle |
78
+ | `resolve_target` | Resolve a natural-language description ("language switcher") to live spotlight targets in the linked tab (JUNE-1257) |
79
+ | `spotlight` | Settle a spotlight ring on a target in the linked tab — a `data-tour` contract anchor or a `resolve_target`/marker `instance:<handle>` (JUNE-621) |
78
80
  | `get_marker_capture` | Receive regions circled with the marker overlay — structural head only, pixels not inlined |
79
81
  | `render_marker_capture` | Render the most recent marker capture as a live MCP App card with spotlight/change/introspect actions (JUNE-623) |
80
82
  | `fetch_marker_crop` | Fetch a marker capture's pixel crop on demand — the lazy-pixel pull |
package/dist/server.js CHANGED
@@ -27,6 +27,7 @@ import { handleApplyInferredTheme } from './tools/apply-inferred-theme.js';
27
27
  import { handleNavigate } from './tools/navigate.js';
28
28
  import { handleSetInstanceVariant } from './tools/set-instance-variant.js';
29
29
  import { handleSpotlight } from './tools/spotlight.js';
30
+ import { handleResolveTarget } from './tools/resolve-target.js';
30
31
  // Session-enact tools register via the single-source list (JUNE-739) so the
31
32
  // registration surface and the reconciliation tests read the same array.
32
33
  import { SESSION_ENACT_TOOLS } from './tools/session-tools.js';
@@ -136,6 +137,8 @@ export async function createRunsnativeServer() {
136
137
  return handleSetInstanceVariant(request.params.arguments ?? {});
137
138
  case 'spotlight':
138
139
  return handleSpotlight(request.params.arguments ?? {});
140
+ case 'resolve_target':
141
+ return handleResolveTarget(request.params.arguments ?? {});
139
142
  case 'get_marker_capture':
140
143
  return handleGetMarkerCapture(request.params.arguments ?? {});
141
144
  case 'render_marker_capture':
@@ -0,0 +1,161 @@
1
+ import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
2
+ import { postWorkerApi, getWorkerApi, getLinkedPairId } from '../bridge-client.js';
3
+ /**
4
+ * resolve_target (JUNE-1257) — the resolution step in front of `spotlight`.
5
+ *
6
+ * The linked tab resolves the description against its LIVE document (the
7
+ * data-run-instance registry plus raw interactive elements) and answers with
8
+ * scored candidates over the command-result return leg. This tool then hands
9
+ * the agent `instance:<handle>` strings that `spotlight` accepts as-is.
10
+ *
11
+ * Loud-failure contract (the ticket's acceptance): no-match, tab-timeout,
12
+ * and in-tab errors all surface as McpError — never as success-shaped text.
13
+ */
14
+ // The tab polls its command queue every 5s; one full cycle plus dispatch and
15
+ // ack comfortably fits in two poll windows. 8 × 2s keeps the worst case ~16s.
16
+ const RESULT_POLL_ATTEMPTS = 8;
17
+ const RESULT_POLL_INTERVAL_MS = 2000;
18
+ export const RESOLVE_TARGET_TOOL = {
19
+ name: 'resolve_target',
20
+ description: "Find where something is on the user's linked runsnative.org tab from a natural-language description — " +
21
+ 'the resolution step in front of `spotlight` for targets that have no pre-authored anchor (most of the ' +
22
+ 'site). Pass a concise noun phrase naming the UI element in the words a label would use ("language ' +
23
+ 'switcher", "changelog link", "search button") — not a whole question. Returns scored candidates, each ' +
24
+ 'with an `instance:<handle>` target string; pick the best match (the signals tell you what each one is) ' +
25
+ 'and pass its target to `spotlight`. Candidates resolve against the live page, so resolve and spotlight ' +
26
+ 'in the same breath — handles do not survive navigation. Requires an active linked session ' +
27
+ '(call link_session first).',
28
+ inputSchema: {
29
+ type: 'object',
30
+ properties: {
31
+ description: {
32
+ type: 'string',
33
+ description: 'What to find, as a short descriptive phrase in the words the UI itself would use ' +
34
+ '("language switcher", "changelog link"). Max 200 characters.',
35
+ },
36
+ limit: {
37
+ type: 'number',
38
+ description: 'Maximum candidates to return (1-10, default 5).',
39
+ },
40
+ },
41
+ required: ['description'],
42
+ },
43
+ // Copresence Protocol §5.1 — session-targeting capability. Read-only in
44
+ // effect (nothing mutated, nothing shown), tier stays 'reversible': the
45
+ // protocol has no lower tier, and resolution is strictly weaker than the
46
+ // spotlight it precedes.
47
+ _meta: {
48
+ copresence: {
49
+ target: 'session',
50
+ tier: 'reversible',
51
+ consent: 'implicit',
52
+ entitlement: 'free',
53
+ visibility: 'advertised',
54
+ enactment: 'tour.resolveTarget',
55
+ },
56
+ },
57
+ };
58
+ function sleep(ms) {
59
+ return new Promise((resolve) => setTimeout(resolve, ms));
60
+ }
61
+ function formatCandidate(c, index) {
62
+ const signals = [`<${c.tag}>`];
63
+ if (c.aria)
64
+ signals.push(`aria-label: "${c.aria}"`);
65
+ if (c.text)
66
+ signals.push(`text: "${c.text}"`);
67
+ if (c.id)
68
+ signals.push(`id: ${c.id}`);
69
+ if (c.icon)
70
+ signals.push(`icon: ${c.icon}`);
71
+ if (c.href)
72
+ signals.push(`href: ${c.href}`);
73
+ return `${index + 1}. ${c.target}\n ${signals.join(' · ')} · score ${c.score}`;
74
+ }
75
+ export async function handleResolveTarget(args) {
76
+ const description = args['description'];
77
+ if (typeof description !== 'string' || !description.trim()) {
78
+ throw new McpError(ErrorCode.InvalidParams, 'description must be a non-empty string');
79
+ }
80
+ if (description.length > 200) {
81
+ throw new McpError(ErrorCode.InvalidParams, 'description must be at most 200 characters');
82
+ }
83
+ const limit = args['limit'];
84
+ if (limit !== undefined && (typeof limit !== 'number' || !Number.isInteger(limit) || limit < 1 || limit > 10)) {
85
+ throw new McpError(ErrorCode.InvalidParams, 'limit must be an integer between 1 and 10 when present');
86
+ }
87
+ const pairId = getLinkedPairId();
88
+ if (!pairId) {
89
+ 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.');
90
+ }
91
+ const commandArgs = [description.trim()];
92
+ if (limit !== undefined)
93
+ commandArgs.push(limit);
94
+ const res = await postWorkerApi('/tenant/command', {
95
+ pair_id: pairId,
96
+ capability: 'tour',
97
+ method: 'resolveTarget',
98
+ args: commandArgs,
99
+ });
100
+ if (res.status === 404) {
101
+ throw new McpError(ErrorCode.InvalidParams, 'Linked session not found or expired. Re-link with link_session.');
102
+ }
103
+ if (res.status === 422) {
104
+ const body = (await res.json().catch(() => ({})));
105
+ throw new McpError(ErrorCode.InvalidParams, body.error ?? 'Command rejected by allow-list.');
106
+ }
107
+ if (res.status === 409) {
108
+ throw new McpError(ErrorCode.InvalidParams, 'No live tab detected. Open runsnative.org, ensure the tab is active, then retry.');
109
+ }
110
+ if (!res.ok) {
111
+ const body = await res.text().catch(() => '');
112
+ throw new McpError(ErrorCode.InternalError, `Worker error ${res.status}: ${body}`);
113
+ }
114
+ const { command_id: commandId } = (await res.json());
115
+ if (!commandId) {
116
+ throw new McpError(ErrorCode.InternalError, 'Worker accepted the command but returned no command_id to poll.');
117
+ }
118
+ const waitedSeconds = Math.round((RESULT_POLL_ATTEMPTS * RESULT_POLL_INTERVAL_MS) / 1000);
119
+ for (let attempt = 0; attempt < RESULT_POLL_ATTEMPTS; attempt++) {
120
+ await sleep(RESULT_POLL_INTERVAL_MS);
121
+ const poll = await getWorkerApi(`/tenant/command/${commandId}/result`);
122
+ if (!poll.ok) {
123
+ const body = await poll.text().catch(() => '');
124
+ throw new McpError(ErrorCode.InternalError, `Worker error ${poll.status} while polling for the result: ${body}`);
125
+ }
126
+ const payload = (await poll.json());
127
+ if (payload.status === 'pending')
128
+ continue;
129
+ if (payload.status === 'expired') {
130
+ throw new McpError(ErrorCode.InternalError, 'The linked tab never picked the command up before it expired. The tab may be closed or backgrounded — bring runsnative.org to the front and retry.');
131
+ }
132
+ if (payload.status === 'no_result') {
133
+ throw new McpError(ErrorCode.InternalError, 'The page acknowledged the command but attached no result — the site build likely predates resolve_target support. The site needs a rebuild before this tool can work against it.');
134
+ }
135
+ // status === 'done'
136
+ const result = payload.result ?? {};
137
+ if (result.status === 'error') {
138
+ throw new McpError(ErrorCode.InternalError, `The page failed to resolve the description: ${result.error ?? 'unknown error'}`);
139
+ }
140
+ if (result.status === 'no_match') {
141
+ throw new McpError(ErrorCode.InvalidParams, `No element on the current page matched "${description.trim()}" (${result.scanned ?? '?'} elements scanned). ` +
142
+ 'Try different words — ideally the ones the UI label itself uses — or navigate to the page the element lives on first.');
143
+ }
144
+ const candidates = result.candidates ?? [];
145
+ if (result.status !== 'ok' || candidates.length === 0) {
146
+ throw new McpError(ErrorCode.InternalError, `The page returned an unrecognized resolution result: ${JSON.stringify(result).slice(0, 300)}`);
147
+ }
148
+ const lines = candidates.map(formatCandidate);
149
+ return {
150
+ content: [
151
+ {
152
+ type: 'text',
153
+ text: `Resolved "${description.trim()}" to ${candidates.length} candidate(s) on the current page:\n\n` +
154
+ `${lines.join('\n')}\n\n` +
155
+ 'Pick the best match and pass its target string to `spotlight` now — instance handles are live-page state and do not survive navigation.',
156
+ },
157
+ ],
158
+ };
159
+ }
160
+ throw new McpError(ErrorCode.InternalError, `The linked tab did not answer within ~${waitedSeconds}s. It may be closed, backgrounded, or on a slow connection — bring runsnative.org to the front and retry.`);
161
+ }
@@ -22,6 +22,7 @@ import { APPLY_INFERRED_THEME_TOOL } from './apply-inferred-theme.js';
22
22
  import { NAVIGATE_TOOL } from './navigate.js';
23
23
  import { SET_INSTANCE_VARIANT_TOOL } from './set-instance-variant.js';
24
24
  import { SPOTLIGHT_TOOL } from './spotlight.js';
25
+ import { RESOLVE_TARGET_TOOL } from './resolve-target.js';
25
26
  export const SESSION_ENACT_TOOLS = [
26
27
  SET_THEME_TOOL,
27
28
  SET_SKIN_TOOL,
@@ -31,6 +32,7 @@ export const SESSION_ENACT_TOOLS = [
31
32
  NAVIGATE_TOOL,
32
33
  SET_INSTANCE_VARIANT_TOOL,
33
34
  SPOTLIGHT_TOOL,
35
+ RESOLVE_TARGET_TOOL,
34
36
  ];
35
37
  /** The `capability.method` allow-list entries this tool set can enact. */
36
38
  export function listSessionEnactments() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runsnative/mcp-server",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist/"