@runsnative/mcp-server 0.5.0 → 0.6.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/server.js CHANGED
@@ -21,6 +21,7 @@ import { SET_THEME_TOOL, handleSetTheme } from './tools/set-theme.js';
21
21
  import { APPLY_INFERRED_THEME_TOOL, handleApplyInferredTheme } from './tools/apply-inferred-theme.js';
22
22
  import { NAVIGATE_TOOL, handleNavigate } from './tools/navigate.js';
23
23
  import { SET_INSTANCE_VARIANT_TOOL, handleSetInstanceVariant } from './tools/set-instance-variant.js';
24
+ import { SPOTLIGHT_TOOL, handleSpotlight } from './tools/spotlight.js';
24
25
  import { GET_MARKER_CAPTURE_TOOL, handleGetMarkerCapture } from './tools/get-marker-capture.js';
25
26
  import { createProvider } from './provider.js';
26
27
  import { RemoteContentProvider } from './remote-provider.js';
@@ -64,6 +65,7 @@ export async function createRunsnativeServer() {
64
65
  APPLY_INFERRED_THEME_TOOL,
65
66
  NAVIGATE_TOOL,
66
67
  SET_INSTANCE_VARIANT_TOOL,
68
+ SPOTLIGHT_TOOL,
67
69
  GET_MARKER_CAPTURE_TOOL,
68
70
  ],
69
71
  }));
@@ -107,6 +109,8 @@ export async function createRunsnativeServer() {
107
109
  return handleNavigate(request.params.arguments ?? {});
108
110
  case 'set_instance_variant':
109
111
  return handleSetInstanceVariant(request.params.arguments ?? {});
112
+ case 'spotlight':
113
+ return handleSpotlight(request.params.arguments ?? {});
110
114
  case 'get_marker_capture':
111
115
  return handleGetMarkerCapture(request.params.arguments ?? {});
112
116
  default:
@@ -72,6 +72,7 @@ const CARD_BODY = `
72
72
  </div>
73
73
  <div class="card-actions">
74
74
  <run-button id="view-btn" variant="primary" size="sm">View on Convene</run-button>
75
+ <run-button id="show-me-btn" variant="secondary" size="sm">Show me where</run-button>
75
76
  </div>
76
77
  <div class="card-status" id="card-status"></div>
77
78
  </article>
@@ -86,6 +87,17 @@ const { App } = globalThis.__MCP_APP_SDK__;
86
87
  const BRAND_ID = /^[a-z0-9][a-z0-9-]*$/;
87
88
  const ACCENT_HEX = /^#[0-9a-fA-F]{3,8}$/;
88
89
 
90
+ // The current gathering's spotlight anchor, captured at render. The anchor
91
+ // mirrors the data-tour="gathering-<id>" the Convene page (compare.js) puts on
92
+ // each card. Sanitized to the safe attribute/URL charset so a hostile id can
93
+ // never break out of the anchor or the deep-link query.
94
+ let spotlightAnchor = null;
95
+
96
+ function anchorFor(id) {
97
+ const safe = String(id == null ? '' : id).replace(/[^A-Za-z0-9_-]/g, '');
98
+ return safe ? 'gathering-' + safe : null;
99
+ }
100
+
89
101
  function setText(id, value) {
90
102
  document.getElementById(id).textContent = value == null ? '' : String(value);
91
103
  }
@@ -94,6 +106,7 @@ function render(payload) {
94
106
  const g = payload && payload.gathering;
95
107
  if (!g) return;
96
108
  const card = document.getElementById('card');
109
+ spotlightAnchor = anchorFor(g.id);
97
110
 
98
111
  // Theme: landed brands get their token-engine CSS (scoped to
99
112
  // :where([data-run-theme="X"])); everything else falls back to the accent.
@@ -150,6 +163,32 @@ document.getElementById('view-btn').addEventListener('click', async () => {
150
163
  }
151
164
  });
152
165
 
166
+ // Deixis (JUNE-621): point the linked tab at THIS gathering — navigate to
167
+ // Convene and settle the spotlight ring on the user's own card. When no tab is
168
+ // linked, degrade to a shareable "spotlight link" (independently valuable, the
169
+ // same deep link a human hands another human).
170
+ document.getElementById('show-me-btn').addEventListener('click', async () => {
171
+ const status = document.getElementById('card-status');
172
+ if (!spotlightAnchor) {
173
+ status.textContent = 'This gathering has no addressable anchor yet.';
174
+ return;
175
+ }
176
+ const target = '/compare/#' + spotlightAnchor;
177
+ const deepLink = 'https://runsnative.org/compare/?spotlight=' + encodeURIComponent(spotlightAnchor);
178
+ status.textContent = 'Pointing your tab at your gathering…';
179
+ try {
180
+ const result = await app.callServerTool({
181
+ name: 'spotlight',
182
+ arguments: { target: target, message: 'Here it is — your gathering, live on Convene.' },
183
+ });
184
+ status.textContent = result && result.isError
185
+ ? 'No linked tab — open your spotlight link: ' + deepLink
186
+ : 'Watch your linked tab — the ring is landing on your gathering.';
187
+ } catch (e) {
188
+ status.textContent = 'No linked tab — open your spotlight link: ' + deepLink;
189
+ }
190
+ });
191
+
153
192
  await app.connect();
154
193
  `;
155
194
  export function buildGatheringCardHtml(assets) {
@@ -136,7 +136,7 @@ console.log('\nget_foundation');
136
136
  console.log('\nlistFoundations');
137
137
  {
138
138
  const foundations = await listFoundations();
139
- check('returns 7 foundations', () => assert(foundations.length === 7, `expected 7 foundations, got ${foundations.length}`));
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 three ready exercises', () => assert(ready.length === 3, `expected 3 ready exercises, got ${ready.length}`));
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
@@ -19,11 +19,11 @@ export const APPLY_INFERRED_THEME_TOOL = {
19
19
  },
20
20
  required: ['brief'],
21
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.
22
+ // Copresence Protocol §5.1 — session-targeting; the first entitlement:'paid'
23
+ // capability on the bridge (JUNE-650). `enactment` names the worker
24
+ // allow-list entry the command leg submits after the paid inference leg succeeds.
25
25
  _meta: {
26
- lsp: {
26
+ copresence: {
27
27
  target: 'session',
28
28
  tier: 'reversible',
29
29
  consent: 'implicit',
@@ -13,12 +13,12 @@ export const GET_MARKER_CAPTURE_TOOL = {
13
13
  type: 'object',
14
14
  properties: {},
15
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).
16
+ // Copresence Protocol §5.1 (copresence-protocol-v0.1, mukadra repo) —
17
+ // session-targeting, but the READ leg of the bridge: it drains the
18
+ // session's capture queue rather than enacting a command, so there is no
19
+ // allow-list `enactment` (the field is optional per §5.1).
20
20
  _meta: {
21
- lsp: {
21
+ copresence: {
22
22
  target: 'session',
23
23
  tier: 'reversible',
24
24
  consent: 'implicit',
@@ -14,11 +14,11 @@ export const NAVIGATE_TOOL = {
14
14
  },
15
15
  required: ['path'],
16
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).
17
+ // Copresence Protocol §5.1 (copresence-protocol-v0.1, mukadra repo) — marks
18
+ // this as a session-targeting capability. `enactment` names the worker
19
+ // allow-list entry this tool submits (JUNE-626).
20
20
  _meta: {
21
- lsp: {
21
+ copresence: {
22
22
  target: 'session',
23
23
  tier: 'reversible',
24
24
  consent: 'implicit',
@@ -23,11 +23,11 @@ export const SET_INSTANCE_VARIANT_TOOL = {
23
23
  },
24
24
  required: ['instance_id', 'variant'],
25
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).
26
+ // Copresence Protocol §5.1 (copresence-protocol-v0.1, mukadra repo) — marks
27
+ // this as a session-targeting capability. `enactment` names the worker
28
+ // allow-list entry this tool submits (JUNE-626).
29
29
  _meta: {
30
- lsp: {
30
+ copresence: {
31
31
  target: 'session',
32
32
  tier: 'reversible',
33
33
  consent: 'implicit',
@@ -14,11 +14,11 @@ export const SET_THEME_TOOL = {
14
14
  },
15
15
  required: ['theme'],
16
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).
17
+ // Copresence Protocol §5.1 (copresence-protocol-v0.1, mukadra repo) — marks
18
+ // this as a session-targeting capability. `enactment` names the worker
19
+ // allow-list entry this tool submits (JUNE-626).
20
20
  _meta: {
21
- lsp: {
21
+ copresence: {
22
22
  target: 'session',
23
23
  tier: 'reversible',
24
24
  consent: 'implicit',
@@ -0,0 +1,85 @@
1
+ import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
2
+ import { postWorkerApi, getLinkedPairId } from '../bridge-client.js';
3
+ export const SPOTLIGHT_TOOL = {
4
+ name: 'spotlight',
5
+ description: "Point at something on the user's linked runsnative.org tab — a spotlight ring settles on a named " +
6
+ 'element while the rest of the page dims (non-modal, they can still interact). Use to SHOW rather than ' +
7
+ 'describe: "show me where X lives", "highlight the Y control". The target is a contract anchor ' +
8
+ '(a data-tour name the design system maintains), optionally prefixed with a route to point across ' +
9
+ 'pages: "/compare/#gathering-g-1" navigates the tab there first, "quick-start" points on the current ' +
10
+ 'page. Optional message shows a small card by the ring. Requires an active linked session (call ' +
11
+ 'link_session first); fully reversible — the ring dismisses on "Got it", Escape, or navigation.',
12
+ inputSchema: {
13
+ type: 'object',
14
+ properties: {
15
+ target: {
16
+ type: 'string',
17
+ description: 'Contract anchor to point at. Bare anchor ("quick-start") points on the current page; ' +
18
+ '"/route#anchor" ("/compare/#gathering-g-1") points across pages, navigating there first.',
19
+ },
20
+ message: {
21
+ type: 'string',
22
+ description: 'Optional short text shown in a card beside the spotlight ring.',
23
+ },
24
+ },
25
+ required: ['target'],
26
+ },
27
+ // Copresence Protocol §5.1 (copresence-protocol-v0.1, mukadra repo) —
28
+ // session-targeting capability. `enactment` names the worker allow-list
29
+ // entry this tool submits.
30
+ _meta: {
31
+ copresence: {
32
+ target: 'session',
33
+ tier: 'reversible',
34
+ consent: 'implicit',
35
+ entitlement: 'free',
36
+ visibility: 'advertised',
37
+ enactment: 'tour.spotlight',
38
+ },
39
+ },
40
+ };
41
+ export async function handleSpotlight(args) {
42
+ const target = args['target'];
43
+ if (typeof target !== 'string' || !target.trim()) {
44
+ throw new McpError(ErrorCode.InvalidParams, 'target must be a non-empty string');
45
+ }
46
+ const message = args['message'];
47
+ if (message !== undefined && typeof message !== 'string') {
48
+ throw new McpError(ErrorCode.InvalidParams, 'message must be a string when present');
49
+ }
50
+ const pairId = getLinkedPairId();
51
+ if (!pairId) {
52
+ 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.');
53
+ }
54
+ const commandArgs = [target.trim()];
55
+ if (typeof message === 'string' && message.trim())
56
+ commandArgs.push(message.trim());
57
+ const res = await postWorkerApi('/tenant/command', {
58
+ pair_id: pairId,
59
+ capability: 'tour',
60
+ method: 'spotlight',
61
+ args: commandArgs,
62
+ });
63
+ if (res.status === 404) {
64
+ throw new McpError(ErrorCode.InvalidParams, 'Linked session not found or expired. Re-link with link_session.');
65
+ }
66
+ if (res.status === 422) {
67
+ const body = (await res.json().catch(() => ({})));
68
+ throw new McpError(ErrorCode.InvalidParams, body.error ?? 'Command rejected by allow-list.');
69
+ }
70
+ if (res.status === 409) {
71
+ throw new McpError(ErrorCode.InvalidParams, 'No live tab detected. Open runsnative.org, ensure the tab is active, then retry.');
72
+ }
73
+ if (!res.ok) {
74
+ const body = await res.text().catch(() => '');
75
+ throw new McpError(ErrorCode.InternalError, `Worker error ${res.status}: ${body}`);
76
+ }
77
+ return {
78
+ content: [
79
+ {
80
+ type: 'text',
81
+ text: `Spotlight command enqueued for "${target.trim()}". The tab will settle the ring within one poll interval.`,
82
+ },
83
+ ],
84
+ };
85
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runsnative/mcp-server",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist/"