@runsnative/mcp-server 0.5.1 → 0.7.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,204 @@
1
+ /**
2
+ * render_surface / surface_event tools (JUNE-793) — the MCP-host leg of the
3
+ * agent-surface runtime. Together they close the engine's render → suspend →
4
+ * resume loop at the user:
5
+ *
6
+ * - `render_surface { route }` — the RENDER leg. Calls the engine gateway's
7
+ * `surfaces/query` (threading the linked-session `agent_session_id`),
8
+ * retains the pairing keys, and returns the `SurfaceSpec` for the agent-
9
+ * surface MCP-App shell to mount.
10
+ * - `surface_event { event_type, payload }` — the RESUME leg. Posts the
11
+ * retained keys + the user's typed payload to `surfaces/{name}/event`,
12
+ * handling the terminal ack, the chained successor, and the governor
13
+ * rejection ("surface no longer active") distinctly.
14
+ *
15
+ * The active surface is held module-level for the MCP server process lifetime
16
+ * (one surface in flight at a time), mirroring bridge-client's `_linkedPairId`.
17
+ * The transport is injectable so tests exercise the full loop with a stub —
18
+ * no engine required.
19
+ */
20
+ import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
21
+ import { getLinkedPairId } from '../bridge-client.js';
22
+ import { componentsBundle, iframeRuntime } from '../surface/assets.js';
23
+ import { AGENT_SURFACE_URI, buildAgentSurfaceHtml } from '../surface/agent-surface.js';
24
+ import { SURFACE_MIME_TYPE } from '../surface/package-surface.js';
25
+ import { SurfaceHostSession, SurfaceHostStateError, } from '../../../surface-protocol/surface-host-client.js';
26
+ import { engineGatewayTransport } from '../surface/engine-gateway-transport.js';
27
+ // ---------------------------------------------------------------------------
28
+ // Module-level state — one active surface per MCP server process, and an
29
+ // injectable transport (default: the engine gateway over HTTP).
30
+ // ---------------------------------------------------------------------------
31
+ let _transport = engineGatewayTransport;
32
+ let _session = null;
33
+ /** Test seam — swap the engine transport for a stub. */
34
+ export function _setSurfaceTransport(t) {
35
+ _transport = t;
36
+ }
37
+ /** Test seam — reset transport + active surface between tests. */
38
+ export function _resetSurfaceHost() {
39
+ _transport = engineGatewayTransport;
40
+ _session = null;
41
+ }
42
+ /** The session identity threaded on both legs: the linked pair, or an env override. */
43
+ function currentAgentSessionId() {
44
+ const linked = getLinkedPairId();
45
+ if (linked)
46
+ return linked;
47
+ const env = process.env['MUKADRA_AGENT_SESSION_ID']?.trim();
48
+ return env ? env : undefined;
49
+ }
50
+ // ---------------------------------------------------------------------------
51
+ // Tool + resource definitions
52
+ // ---------------------------------------------------------------------------
53
+ export const RENDER_SURFACE_TOOL = {
54
+ name: 'render_surface',
55
+ description: 'Render an agent surface at a route: ask the Mukadra engine what component to render there, ' +
56
+ 'mount it as a live MCP App in this conversation, and suspend for the user to interact. The ' +
57
+ "user's typed response is posted back with surface_event, and the agent continues. Use when an " +
58
+ 'agent reasoning step needs a user pick that a component surface can capture (the render → ' +
59
+ 'suspend → resume loop). Requires a linked session so the resume is governed (resumable); ' +
60
+ 'without one the surface renders non-resumable (one-shot).',
61
+ inputSchema: {
62
+ type: 'object',
63
+ properties: {
64
+ route: {
65
+ type: 'string',
66
+ description: "The surface route to resolve (e.g. '/pick/customer'). Matched by the engine's surface catalog.",
67
+ },
68
+ },
69
+ required: ['route'],
70
+ },
71
+ _meta: {
72
+ ui: { resourceUri: AGENT_SURFACE_URI },
73
+ 'ui/resourceUri': AGENT_SURFACE_URI,
74
+ },
75
+ };
76
+ export const SURFACE_EVENT_TOOL = {
77
+ name: 'surface_event',
78
+ description: 'Post the user\'s response to the active surface back to the engine (the resume leg). Called by ' +
79
+ 'the surface shell when the user interacts; resolves the suspended agent step. Returns the ' +
80
+ 'outcome: done (agent continues), chained (a successor surface to render), or expired (the ' +
81
+ 'surface is no longer active).',
82
+ inputSchema: {
83
+ type: 'object',
84
+ properties: {
85
+ event_type: { type: 'string', description: "Interaction type, e.g. 'submit', 'click', 'cancel'. Defaults to 'submit'." },
86
+ payload: { description: "The user's typed response, shaped by the surface's response contract." },
87
+ },
88
+ required: [],
89
+ },
90
+ };
91
+ export const AGENT_SURFACE_RESOURCE = {
92
+ uri: AGENT_SURFACE_URI,
93
+ name: 'Agent Surface',
94
+ description: 'Self-contained MCP App shell that mounts whatever component the engine surfaces at a route and ' +
95
+ 'posts the user response back. Spec arrives via the tool-result notification; nothing external is loaded.',
96
+ mimeType: SURFACE_MIME_TYPE,
97
+ };
98
+ export async function readAgentSurfaceResource() {
99
+ const html = buildAgentSurfaceHtml({
100
+ componentsBundle: componentsBundle(),
101
+ iframeRuntime: iframeRuntime(),
102
+ });
103
+ return { contents: [{ uri: AGENT_SURFACE_URI, mimeType: SURFACE_MIME_TYPE, text: html }] };
104
+ }
105
+ // ---------------------------------------------------------------------------
106
+ // Handlers
107
+ // ---------------------------------------------------------------------------
108
+ export async function handleRenderSurface(args) {
109
+ const route = typeof args['route'] === 'string' ? args['route'].trim() : '';
110
+ if (!route) {
111
+ throw new McpError(ErrorCode.InvalidParams, 'render_surface requires a non-empty "route".');
112
+ }
113
+ const session = new SurfaceHostSession({
114
+ transport: _transport,
115
+ agentSessionId: currentAgentSessionId(),
116
+ });
117
+ const outcome = await session.render(route);
118
+ if (outcome.status === 'not_found') {
119
+ // No declared surface matches this route (render-leg 404). Distinct from a
120
+ // governor rejection, which can only happen on the resume leg.
121
+ throw new McpError(ErrorCode.InvalidParams, `No surface is declared at route "${route}". Check the route, or the agent's surface catalog.`);
122
+ }
123
+ // Retain the active surface for the subsequent surface_event call.
124
+ _session = session;
125
+ const resumableNote = outcome.resumable
126
+ ? 'The surface is governed (resumable) — interact, and surface_event resumes the agent.'
127
+ : 'No linked session — this surface is NON-RESUMABLE (one-shot); link a session first to enable resume.';
128
+ return {
129
+ content: [
130
+ {
131
+ type: 'text',
132
+ text: `Rendered surface "${outcome.surfaceName}" (${outcome.spec.component}) at route "${route}". ` +
133
+ resumableNote,
134
+ },
135
+ ],
136
+ structuredContent: {
137
+ spec: outcome.spec,
138
+ surface_instance_id: outcome.surfaceInstanceId,
139
+ correlation_id: outcome.correlationId,
140
+ resumable: outcome.resumable,
141
+ },
142
+ };
143
+ }
144
+ export async function handleSurfaceEvent(args) {
145
+ if (!_session || !_session.hasActiveSurface) {
146
+ throw new McpError(ErrorCode.InvalidParams, 'No active surface to respond to. Render one first with render_surface.');
147
+ }
148
+ const eventType = typeof args['event_type'] === 'string' && args['event_type'].trim() ? args['event_type'].trim() : 'submit';
149
+ const payload = args['payload'];
150
+ let outcome;
151
+ try {
152
+ outcome = await _session.submit(eventType, payload);
153
+ }
154
+ catch (e) {
155
+ if (e instanceof SurfaceHostStateError) {
156
+ // Non-resumable (session-less) surface, or ordering violation — a clean
157
+ // host-side signal, not a crash.
158
+ _session = null;
159
+ throw new McpError(ErrorCode.InvalidParams, e.message);
160
+ }
161
+ throw e;
162
+ }
163
+ switch (outcome.status) {
164
+ case 'done':
165
+ _session = null;
166
+ return {
167
+ content: [{ type: 'text', text: 'Response accepted — the agent is continuing.' }],
168
+ structuredContent: { status: 'done', correlation_id: outcome.correlationId },
169
+ };
170
+ case 'chained':
171
+ // Successor surface parked by the engine (same correlation_id). The shell
172
+ // re-mounts spec and loops.
173
+ return {
174
+ content: [
175
+ { type: 'text', text: `Rendered the next surface (${outcome.spec.component}). Waiting for the user's response.` },
176
+ ],
177
+ structuredContent: {
178
+ status: 'chained',
179
+ spec: outcome.spec,
180
+ surface_instance_id: outcome.surfaceInstanceId,
181
+ correlation_id: outcome.correlationId,
182
+ },
183
+ };
184
+ case 'expired':
185
+ // Governor rejection — expired / cross-session / already-resolved. This
186
+ // is the "surface no longer active" state, distinct from a 404.
187
+ _session = null;
188
+ return {
189
+ content: [
190
+ {
191
+ type: 'text',
192
+ text: 'That surface is no longer active (it expired or was already resolved). Render it again to continue.',
193
+ },
194
+ ],
195
+ structuredContent: { status: 'expired' },
196
+ };
197
+ case 'not_found':
198
+ _session = null;
199
+ return {
200
+ content: [{ type: 'text', text: 'That surface is no longer available (unknown surface or route).' }],
201
+ structuredContent: { status: 'not_found' },
202
+ };
203
+ }
204
+ }
@@ -1,7 +1,7 @@
1
1
  import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
2
2
  export const SEARCH_DOCS_TOOL = {
3
3
  name: 'search_docs',
4
- description: 'Lexical search across all RunsNative component documentation, foundations, and exercises. ' +
4
+ description: 'Lexical search across all RunsNative component documentation, foundations, exercises, and composition patterns (recipes). ' +
5
5
  'Useful when you do not know the exact component name — for example, searching "focus ring token" ' +
6
6
  'or "which component handles multi-select". Returns scored results with excerpts.',
7
7
  inputSchema: {
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The session-ENACT tool set — every MCP tool that submits a command into a
3
+ * linked session (Copresence Protocol §5.1 `target: 'session'` with an
4
+ * `enactment`). Single source of truth (JUNE-739): server.ts registers from
5
+ * this list, copresence-meta.test.ts asserts annotations over it, and the
6
+ * cross-surface reconciliation test (mcp-api-worker
7
+ * test/cross-surface-reconciliation.test.ts) binds it to the worker
8
+ * allow-list, entitlement registry, and org DISPATCH_TABLE.
9
+ *
10
+ * Adding a session verb? Add the tool HERE (never only in server.ts) — the
11
+ * reconciliation test fails until all four surfaces carry it.
12
+ *
13
+ * NOT in this list: read-leg session tools with no enactment
14
+ * (get_marker_capture, fetch_marker_crop, render_marker_capture) and the
15
+ * binding-redemption tool (link_session).
16
+ */
17
+ import { SET_THEME_TOOL } from './set-theme.js';
18
+ import { SET_SKIN_TOOL } from './set-skin.js';
19
+ import { SET_MODE_TOOL } from './set-mode.js';
20
+ import { SET_CONTRAST_TOOL } from './set-contrast.js';
21
+ import { APPLY_INFERRED_THEME_TOOL } from './apply-inferred-theme.js';
22
+ import { NAVIGATE_TOOL } from './navigate.js';
23
+ import { SET_INSTANCE_VARIANT_TOOL } from './set-instance-variant.js';
24
+ import { SPOTLIGHT_TOOL } from './spotlight.js';
25
+ export const SESSION_ENACT_TOOLS = [
26
+ SET_THEME_TOOL,
27
+ SET_SKIN_TOOL,
28
+ SET_MODE_TOOL,
29
+ SET_CONTRAST_TOOL,
30
+ APPLY_INFERRED_THEME_TOOL,
31
+ NAVIGATE_TOOL,
32
+ SET_INSTANCE_VARIANT_TOOL,
33
+ SPOTLIGHT_TOOL,
34
+ ];
35
+ /** The `capability.method` allow-list entries this tool set can enact. */
36
+ export function listSessionEnactments() {
37
+ return SESSION_ENACT_TOOLS.map((t) => t._meta?.copresence?.enactment).filter((e) => typeof e === 'string');
38
+ }
@@ -0,0 +1,59 @@
1
+ import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
2
+ import { postWorkerApi, getLinkedPairId } from '../bridge-client.js';
3
+ export const SET_CONTRAST_TOOL = {
4
+ name: 'set_contrast',
5
+ description: "Switch the user's linked runsnative.org tab between standard and high contrast — the accessibility axis of the " +
6
+ 'theme, orthogonal to light/dark mode. Use when the user asks for higher contrast, better legibility, or to go ' +
7
+ 'back to the standard rendering. The command is enqueued and the tab applies it within one poll interval; ' +
8
+ 'nothing is saved — fully reversible. Requires an active linked session (call link_session first).',
9
+ inputSchema: {
10
+ type: 'object',
11
+ properties: {
12
+ contrast: { type: 'string', enum: ['standard', 'high'], description: "Contrast to apply: 'standard' or 'high'." },
13
+ },
14
+ required: ['contrast'],
15
+ },
16
+ // Copresence Protocol §5.1 — session-targeting capability; `enactment` names
17
+ // the worker allow-list entry this tool submits (JUNE-626 / JUNE-739).
18
+ _meta: {
19
+ copresence: {
20
+ target: 'session',
21
+ tier: 'reversible',
22
+ consent: 'implicit',
23
+ entitlement: 'free',
24
+ visibility: 'advertised',
25
+ enactment: 'theme.setContrast',
26
+ },
27
+ },
28
+ };
29
+ export async function handleSetContrast(args) {
30
+ const contrast = args['contrast'];
31
+ if (contrast !== 'standard' && contrast !== 'high') {
32
+ throw new McpError(ErrorCode.InvalidParams, "contrast must be 'standard' or 'high'");
33
+ }
34
+ const pairId = getLinkedPairId();
35
+ if (!pairId) {
36
+ 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.');
37
+ }
38
+ const res = await postWorkerApi('/tenant/command', {
39
+ pair_id: pairId,
40
+ capability: 'theme',
41
+ method: 'setContrast',
42
+ args: [contrast],
43
+ });
44
+ if (res.status === 404) {
45
+ throw new McpError(ErrorCode.InvalidParams, 'Linked session not found or expired. Re-link with link_session.');
46
+ }
47
+ if (res.status === 422) {
48
+ const body = await res.json().catch(() => ({}));
49
+ throw new McpError(ErrorCode.InvalidParams, body.error ?? 'Command rejected by allow-list.');
50
+ }
51
+ if (res.status === 409) {
52
+ throw new McpError(ErrorCode.InvalidParams, 'No live tab detected. Open runsnative.org, ensure the tab is active, then retry.');
53
+ }
54
+ if (!res.ok) {
55
+ const body = await res.text().catch(() => '');
56
+ throw new McpError(ErrorCode.InternalError, `Worker error ${res.status}: ${body}`);
57
+ }
58
+ return { content: [{ type: 'text', text: `Contrast command enqueued: ${contrast}. The tab will apply it within one poll interval.` }] };
59
+ }
@@ -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',
@@ -0,0 +1,59 @@
1
+ import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
2
+ import { postWorkerApi, getLinkedPairId } from '../bridge-client.js';
3
+ export const SET_MODE_TOOL = {
4
+ name: 'set_mode',
5
+ description: "Switch the user's linked runsnative.org tab between light and dark mode — the luminance axis of the theme, " +
6
+ 'orthogonal to contrast. Use when the user asks for dark mode, light mode, or to compare the two. The command ' +
7
+ 'is enqueued and the tab applies it within one poll interval; nothing is saved — fully reversible. Requires an ' +
8
+ 'active linked session (call link_session first).',
9
+ inputSchema: {
10
+ type: 'object',
11
+ properties: {
12
+ mode: { type: 'string', enum: ['light', 'dark'], description: "Mode to apply: 'light' or 'dark'." },
13
+ },
14
+ required: ['mode'],
15
+ },
16
+ // Copresence Protocol §5.1 — session-targeting capability; `enactment` names
17
+ // the worker allow-list entry this tool submits (JUNE-626 / JUNE-739).
18
+ _meta: {
19
+ copresence: {
20
+ target: 'session',
21
+ tier: 'reversible',
22
+ consent: 'implicit',
23
+ entitlement: 'free',
24
+ visibility: 'advertised',
25
+ enactment: 'theme.setMode',
26
+ },
27
+ },
28
+ };
29
+ export async function handleSetMode(args) {
30
+ const mode = args['mode'];
31
+ if (mode !== 'light' && mode !== 'dark') {
32
+ throw new McpError(ErrorCode.InvalidParams, "mode must be 'light' or 'dark'");
33
+ }
34
+ const pairId = getLinkedPairId();
35
+ if (!pairId) {
36
+ 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.');
37
+ }
38
+ const res = await postWorkerApi('/tenant/command', {
39
+ pair_id: pairId,
40
+ capability: 'theme',
41
+ method: 'setMode',
42
+ args: [mode],
43
+ });
44
+ if (res.status === 404) {
45
+ throw new McpError(ErrorCode.InvalidParams, 'Linked session not found or expired. Re-link with link_session.');
46
+ }
47
+ if (res.status === 422) {
48
+ const body = await res.json().catch(() => ({}));
49
+ throw new McpError(ErrorCode.InvalidParams, body.error ?? 'Command rejected by allow-list.');
50
+ }
51
+ if (res.status === 409) {
52
+ throw new McpError(ErrorCode.InvalidParams, 'No live tab detected. Open runsnative.org, ensure the tab is active, then retry.');
53
+ }
54
+ if (!res.ok) {
55
+ const body = await res.text().catch(() => '');
56
+ throw new McpError(ErrorCode.InternalError, `Worker error ${res.status}: ${body}`);
57
+ }
58
+ return { content: [{ type: 'text', text: `Mode command enqueued: ${mode}. The tab will apply it within one poll interval.` }] };
59
+ }
@@ -0,0 +1,69 @@
1
+ import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
2
+ import { postWorkerApi } from '../bridge-client.js';
3
+ // An ORDINARY (non-session-targeting) authenticated MCP tool. Unlike the
4
+ // session-ENACT tools (set_theme, set_instance_variant, …), this does NOT drive a
5
+ // live bound tab through a pair — it persists content to the caller's PREVIEW
6
+ // namespace via the tenant token, and the preview site renders it on next load.
7
+ // Per Copresence Protocol §5.1, a tool that does not target a bound live session
8
+ // is an ordinary MCP tool, so it carries NO _meta.copresence and is NOT in
9
+ // SESSION_ENACT_TOOLS (see copresence-meta.test.ts: "Content tools likewise").
10
+ export const SET_SITE_CONTENT_TOOL = {
11
+ name: 'set_site_content',
12
+ description: "Edit a section of the user's own site content in their PREVIEW environment. The change is " +
13
+ 'saved to preview ONLY — the staging and production versions of the site are never touched — and ' +
14
+ 'the preview site shows it on next load. Use this for content edits the user describes in words ' +
15
+ '(e.g. "we\'re past the summer closings, remove them from les heures"). Provide the section key and ' +
16
+ 'the FULL new content object for that section — it replaces the current preview content. The shape ' +
17
+ 'is section-specific and validated server-side; an invalid shape is rejected. Requires an ' +
18
+ 'authenticated RunsNative token whose role may edit this site.',
19
+ inputSchema: {
20
+ type: 'object',
21
+ properties: {
22
+ section_key: {
23
+ type: 'string',
24
+ description: "The section to edit, page-namespaced, e.g. 'index:les-heures'.",
25
+ },
26
+ content: {
27
+ type: 'object',
28
+ description: 'The full replacement content object for the section (section-specific shape, validated ' +
29
+ 'server-side).',
30
+ },
31
+ },
32
+ required: ['section_key', 'content'],
33
+ },
34
+ };
35
+ export async function handleSetSiteContent(args) {
36
+ const sectionKey = args['section_key'];
37
+ if (typeof sectionKey !== 'string' || !sectionKey.trim()) {
38
+ throw new McpError(ErrorCode.InvalidParams, 'section_key must be a non-empty string');
39
+ }
40
+ const content = args['content'];
41
+ if (!content || typeof content !== 'object' || Array.isArray(content)) {
42
+ throw new McpError(ErrorCode.InvalidParams, 'content must be an object');
43
+ }
44
+ const res = await postWorkerApi('/content', { section_key: sectionKey.trim(), content });
45
+ if (res.status === 401) {
46
+ throw new McpError(ErrorCode.InvalidParams, 'Not authenticated. Set RUNSNATIVE_TENANT_TOKEN to a token that may edit this site.');
47
+ }
48
+ if (res.status === 403) {
49
+ throw new McpError(ErrorCode.InvalidParams, 'Your role does not permit editing this site.');
50
+ }
51
+ if (res.status === 400) {
52
+ const body = (await res.json().catch(() => ({})));
53
+ throw new McpError(ErrorCode.InvalidParams, body.error ?? 'Content rejected by the server.');
54
+ }
55
+ if (!res.ok) {
56
+ const body = await res.text().catch(() => '');
57
+ throw new McpError(ErrorCode.InternalError, `Worker error ${res.status}: ${body}`);
58
+ }
59
+ const body = (await res.json());
60
+ return {
61
+ content: [
62
+ {
63
+ type: 'text',
64
+ text: `Saved "${body.section_key}" to your preview site (version ${body.version}). ` +
65
+ 'Reload the preview to see it — staging and production are unchanged.',
66
+ },
67
+ ],
68
+ };
69
+ }
@@ -0,0 +1,60 @@
1
+ import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
2
+ import { postWorkerApi, getLinkedPairId } from '../bridge-client.js';
3
+ export const SET_SKIN_TOOL = {
4
+ name: 'set_skin',
5
+ description: "Apply a named skin to the user's linked runsnative.org tab — the rendering treatment (e.g. wireframe, " +
6
+ 'glassmorphism, crystalline) layered over the active theme, orthogonal to theme, mode, and contrast. Use when ' +
7
+ 'the user asks to try a different look-and-feel treatment. The command is enqueued and the tab applies it ' +
8
+ 'within one poll interval; nothing is saved — fully reversible. Requires an active linked session (call ' +
9
+ 'link_session first).',
10
+ inputSchema: {
11
+ type: 'object',
12
+ properties: {
13
+ skin: { type: 'string', description: 'Skin identifier, e.g. "wireframe" or "glassmorphism".' },
14
+ },
15
+ required: ['skin'],
16
+ },
17
+ // Copresence Protocol §5.1 — session-targeting capability; `enactment` names
18
+ // the worker allow-list entry this tool submits (JUNE-626 / JUNE-739).
19
+ _meta: {
20
+ copresence: {
21
+ target: 'session',
22
+ tier: 'reversible',
23
+ consent: 'implicit',
24
+ entitlement: 'free',
25
+ visibility: 'advertised',
26
+ enactment: 'theme.setSkin',
27
+ },
28
+ },
29
+ };
30
+ export async function handleSetSkin(args) {
31
+ const skin = args['skin'];
32
+ if (typeof skin !== 'string' || !skin.trim()) {
33
+ throw new McpError(ErrorCode.InvalidParams, 'skin must be a non-empty string');
34
+ }
35
+ const pairId = getLinkedPairId();
36
+ if (!pairId) {
37
+ 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.');
38
+ }
39
+ const res = await postWorkerApi('/tenant/command', {
40
+ pair_id: pairId,
41
+ capability: 'theme',
42
+ method: 'setSkin',
43
+ args: [skin.trim()],
44
+ });
45
+ if (res.status === 404) {
46
+ throw new McpError(ErrorCode.InvalidParams, 'Linked session not found or expired. Re-link with link_session.');
47
+ }
48
+ if (res.status === 422) {
49
+ const body = await res.json().catch(() => ({}));
50
+ throw new McpError(ErrorCode.InvalidParams, body.error ?? 'Command rejected by allow-list.');
51
+ }
52
+ if (res.status === 409) {
53
+ throw new McpError(ErrorCode.InvalidParams, 'No live tab detected. Open runsnative.org, ensure the tab is active, then retry.');
54
+ }
55
+ if (!res.ok) {
56
+ const body = await res.text().catch(() => '');
57
+ throw new McpError(ErrorCode.InternalError, `Worker error ${res.status}: ${body}`);
58
+ }
59
+ return { content: [{ type: 'text', text: `Skin command enqueued: ${skin.trim()}. The tab will apply it within one poll interval.` }] };
60
+ }
@@ -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.1",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist/"