@runsnative/mcp-server 0.10.0 → 0.13.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
@@ -139,9 +139,11 @@ detour on top of the token cost.
139
139
 
140
140
  ## For RunsNative repo developers
141
141
 
142
- If you're working inside the RunsNative repo, use the `.mcp.json` at the repo root. It points at `packages/mcp-server/dist/index.js` directly and sets `RUNSNATIVE_CONTENT_ROOT` so you get live content from your working tree without hitting the cloud API.
142
+ If you're working inside the RunsNative repo, use the `.mcp.json` at the repo root. It runs the launcher `tools/mcp/runsnative-mcp.mjs` and sets `RUNSNATIVE_CONTENT_ROOT` so you get live content from your working tree without hitting the cloud API.
143
143
 
144
- Build first:
144
+ `dist/` is gitignored, so git worktrees never have one. The launcher serves this checkout's own `dist/index.js` when it exists, and otherwise the main checkout's (found via `git rev-parse --git-common-dir`). It refuses loudly — a failed MCP server at session start, with the reason in the MCP log — when there is no build, or when the build is older than a non-test file in its `src/` (a tool merged since would otherwise be silently missing). `RUNSNATIVE_MCP_ALLOW_STALE=1` serves a stale build anyway. It never builds for you (RUN-662).
145
+
146
+ Build once, in the main checkout, and again after any `src/` change lands there:
145
147
 
146
148
  ```bash
147
149
  cd packages/mcp-server && npm run build
package/dist/content.js CHANGED
@@ -2,6 +2,7 @@ import { readFile, readdir } from 'node:fs/promises';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
+ import { isListed, discoveryFields } from './discovery.js';
5
6
  const VALID_TABS = ['usage', 'style', 'code', 'accessibility'];
6
7
  // Content store roots. fileURLToPath handles Windows drive letters correctly
7
8
  // (avoids /C:/C:/ doubling).
@@ -68,7 +69,8 @@ export async function listComponentMeta(includeDrafts = false) {
68
69
  const raw = await readFile(indexPath, 'utf8');
69
70
  const fm = parseFrontmatter(raw);
70
71
  const status = fm.status ?? 'unknown';
71
- if (!includeDrafts && status !== 'ready')
72
+ const discoverable = { status, signed_off: fm.signed_off };
73
+ if (!isListed(discoverable, includeDrafts))
72
74
  continue;
73
75
  results.push({
74
76
  name: entry.name,
@@ -77,6 +79,7 @@ export async function listComponentMeta(includeDrafts = false) {
77
79
  surface: fm.surface ?? 'unknown',
78
80
  status,
79
81
  purpose: fm.purpose ?? '',
82
+ ...discoveryFields(discoverable),
80
83
  });
81
84
  }
82
85
  return results.sort((a, b) => a.name.localeCompare(b.name));
@@ -0,0 +1,44 @@
1
+ // Component discovery rule — RUN-731, founder ruling D1 (2026-09-19).
2
+ //
3
+ // Two facts, two fields, never collapsed into one:
4
+ // `status` human content review (content schema §11). Only a reviewed
5
+ // entry is ever `ready`.
6
+ // `signed_off` the component itself is signed off for public docs (live
7
+ // inventory `signed_off && audience === 'public-docs'`), kept in
8
+ // sync by check-kb-drift.mjs.
9
+ //
10
+ // Discovery follows sign-off: by default `list_components` returns every
11
+ // `ready` entry PLUS every signed-off entry whose content is still `draft`,
12
+ // each marked so an agent can tell the docs are unreviewed. Drafts of
13
+ // components that are NOT signed off stay hidden unless include_drafts.
14
+ //
15
+ // Pure and dependency-free on purpose: the local provider, the remote provider
16
+ // and the hosted worker (packages/mcp-api-worker, a Cloudflare Worker with no
17
+ // node:fs) all import this one module, so the three listings cannot disagree.
18
+ export const UNREVIEWED_NOTE = 'The component is signed off, but these docs have not had human content review yet. ' +
19
+ 'They may be machine-generated or incomplete, including the accessibility tab. ' +
20
+ 'Verify against the component before relying on them.';
21
+ // Frontmatter and the KV manifest both arrive as strings; accept either form.
22
+ export function isSignedOff(value) {
23
+ return value === true || value === 'true';
24
+ }
25
+ export function isListed(entry, includeDrafts) {
26
+ if (includeDrafts)
27
+ return true;
28
+ if (entry.status === 'ready')
29
+ return true;
30
+ return entry.status === 'draft' && isSignedOff(entry.signed_off);
31
+ }
32
+ export function contentReview(entry) {
33
+ return entry.status === 'ready' ? 'reviewed' : 'unreviewed draft';
34
+ }
35
+ // The discovery fields every listing attaches to a component, in one place.
36
+ export function discoveryFields(entry) {
37
+ const review = contentReview(entry);
38
+ const signed = isSignedOff(entry.signed_off);
39
+ return {
40
+ signed_off: signed,
41
+ content_review: review,
42
+ ...(review === 'unreviewed draft' && signed ? { content_note: UNREVIEWED_NOTE } : {}),
43
+ };
44
+ }
@@ -2,6 +2,7 @@ import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
2
2
  import { join, dirname } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
5
+ import { isListed, discoveryFields } from './discovery.js';
5
6
  // ---------------------------------------------------------------------------
6
7
  // Helpers — copied from content.ts to avoid importing filesystem-bound code
7
8
  // ---------------------------------------------------------------------------
@@ -82,7 +83,7 @@ export class RemoteContentProvider {
82
83
  async listComponents(includeDrafts = false) {
83
84
  const manifest = await this.getManifest();
84
85
  return manifest.components
85
- .filter(e => includeDrafts || e.status === 'ready')
86
+ .filter(e => isListed(e, includeDrafts))
86
87
  .map(e => ({
87
88
  name: e.name,
88
89
  title: e.title,
@@ -90,6 +91,7 @@ export class RemoteContentProvider {
90
91
  surface: e.surface,
91
92
  status: e.status,
92
93
  purpose: e.purpose,
94
+ ...discoveryFields(e),
93
95
  }))
94
96
  .sort((a, b) => a.name.localeCompare(b.name));
95
97
  }
package/dist/server.js CHANGED
@@ -32,6 +32,7 @@ import { handleNavigate } from './tools/navigate.js';
32
32
  import { handleSetInstanceVariant } from './tools/set-instance-variant.js';
33
33
  import { handleSpotlight } from './tools/spotlight.js';
34
34
  import { handleResolveTarget } from './tools/resolve-target.js';
35
+ import { handleSetSufficiency } from './tools/set-sufficiency.js';
35
36
  // Session-enact tools register via the single-source list (JUNE-739) so the
36
37
  // registration surface and the reconciliation tests read the same array.
37
38
  import { SESSION_ENACT_TOOLS } from './tools/session-tools.js';
@@ -155,6 +156,8 @@ export async function createRunsnativeServer() {
155
156
  return handleSpotlight(request.params.arguments ?? {});
156
157
  case 'resolve_target':
157
158
  return handleResolveTarget(request.params.arguments ?? {});
159
+ case 'set_sufficiency':
160
+ return handleSetSufficiency(request.params.arguments ?? {});
158
161
  case 'get_marker_capture':
159
162
  return handleGetMarkerCapture(request.params.arguments ?? {});
160
163
  case 'render_marker_capture':
@@ -2,14 +2,16 @@ import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
2
2
  export const LIST_COMPONENTS_TOOL = {
3
3
  name: 'list_components',
4
4
  description: 'Lists RunsNative components available in the knowledge base. ' +
5
- 'Returns name, title, element tag, surface area, status, and purpose for each component. ' +
6
- 'By default returns only ready components. Pass include_drafts: true to include draft stubs.',
5
+ 'Returns name, title, element tag, surface area, status, purpose, signed_off and content_review for each component. ' +
6
+ 'By default returns every component signed off for use: those with reviewed docs (status "ready") and those ' +
7
+ 'whose docs are still an unreviewed draft (content_review "unreviewed draft" — treat that content as a starting ' +
8
+ 'point, not verified guidance). Pass include_drafts: true to also include components not yet signed off.',
7
9
  inputSchema: {
8
10
  type: 'object',
9
11
  properties: {
10
12
  include_drafts: {
11
13
  type: 'boolean',
12
- description: 'When true, includes draft/stub components in addition to ready ones. Default: false.',
14
+ description: 'When true, also includes components that are not signed off yet. Default: false.',
13
15
  },
14
16
  },
15
17
  required: [],
@@ -8,7 +8,17 @@
8
8
  * allow-list, entitlement registry, and org DISPATCH_TABLE.
9
9
  *
10
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.
11
+ * reconciliation test fails until all four of those surfaces carry it.
12
+ *
13
+ * A FIFTH surface then has to account for it, and it is easy to miss because a
14
+ * different test owns it: the remote HTTP transport (mcp-api-worker
15
+ * src/mcp/tools.ts, asserted by test/mcp/remote-surface.test.ts, JUNE-1297).
16
+ * Every stdio tool must be either bound there or listed in
17
+ * DEFERRED_FROM_REMOTE with the reason it is not — absence has to be a decision
18
+ * someone wrote down rather than a smaller surface nobody noticed shipping.
19
+ * Binding is a positional projection of named args (ARG_PROJECTIONS), so a verb
20
+ * whose argument is one structured object (intake.setSufficiency, JUNE-1577) or
21
+ * whose args are built conditionally (tour.spotlight) is deferred instead.
12
22
  *
13
23
  * NOT in this list: read-leg session tools with no enactment
14
24
  * (get_marker_capture, fetch_marker_crop, render_marker_capture) and the
@@ -23,6 +33,7 @@ import { NAVIGATE_TOOL } from './navigate.js';
23
33
  import { SET_INSTANCE_VARIANT_TOOL } from './set-instance-variant.js';
24
34
  import { SPOTLIGHT_TOOL } from './spotlight.js';
25
35
  import { RESOLVE_TARGET_TOOL } from './resolve-target.js';
36
+ import { SET_SUFFICIENCY_TOOL } from './set-sufficiency.js';
26
37
  export const SESSION_ENACT_TOOLS = [
27
38
  SET_THEME_TOOL,
28
39
  SET_SKIN_TOOL,
@@ -33,6 +44,7 @@ export const SESSION_ENACT_TOOLS = [
33
44
  SET_INSTANCE_VARIANT_TOOL,
34
45
  SPOTLIGHT_TOOL,
35
46
  RESOLVE_TARGET_TOOL,
47
+ SET_SUFFICIENCY_TOOL,
36
48
  ];
37
49
  /** The `capability.method` allow-list entries this tool set can enact. */
38
50
  export function listSessionEnactments() {
@@ -0,0 +1,146 @@
1
+ import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
2
+ import { postWorkerApi, getLinkedPairId } from '../bridge-client.js';
3
+ /**
4
+ * The intake sufficiency block (JUNE-1577 leg A).
5
+ *
6
+ * The wire shape is the `state` object JUNE-1430's `record_answer` returns —
7
+ * an interview session relays it here after every accepted answer and the
8
+ * paired tab's meter panel redraws. The worker's allow-list
9
+ * (`mcp-api-worker/src/handlers/pair/sufficiency-block.ts`) is the single
10
+ * authority on the block's vocabulary; this tool checks only that the three
11
+ * required containers are present, so an obvious mistake fails locally instead
12
+ * of costing a round trip, and lets the worker name anything subtler.
13
+ *
14
+ * There is no `label` on the wire, by ruling: the eight dimension labels are
15
+ * byte-frozen in the model's §2 and the page holds them keyed by `n`.
16
+ */
17
+ export const SET_SUFFICIENCY_TOOL = {
18
+ name: 'set_sufficiency',
19
+ description: "Push the intake sufficiency block to the user's linked runsnative.org interview page — the eight-dimension " +
20
+ 'meter panel redraws within one poll interval. Call it after every answer you accept during a partner intake ' +
21
+ "interview, passing the `state` object from the interview skill's record_answer verbatim, so the person " +
22
+ 'answering watches the meters move and can see how close the intake is to done. Display-only: the page shows ' +
23
+ 'what you send and scores nothing itself, so the block you send IS what they see. Reversible — the next block ' +
24
+ 'replaces it, and a reload clears the panel. Requires an active linked session (call link_session first).',
25
+ inputSchema: {
26
+ type: 'object',
27
+ properties: {
28
+ rows: {
29
+ type: 'array',
30
+ description: 'Exactly 8 rows, one per dimension, in the model\'s order 1-8. Each is {n, band, source, next}: `band` is ' +
31
+ 'unknown|developing|nearly|sufficient|strong; `source` is partner|artifact|modal|research|confirmed, and ' +
32
+ 'must be the em-dash "—" exactly when the band is unknown; `next` says what would move the row ("—" when ' +
33
+ 'the row is settled). No `label` — the page holds the frozen dimension labels itself.',
34
+ items: {
35
+ type: 'object',
36
+ properties: {
37
+ n: { type: 'integer', minimum: 1, maximum: 8, description: 'Dimension number, matching the row position.' },
38
+ band: {
39
+ type: 'string',
40
+ enum: ['unknown', 'developing', 'nearly', 'sufficient', 'strong'],
41
+ description: 'How well the intake knows this dimension.',
42
+ },
43
+ source: {
44
+ type: 'string',
45
+ enum: ['partner', 'artifact', 'modal', 'research', 'confirmed', '—'],
46
+ description: 'What supplied it. "—" exactly when the band is unknown.',
47
+ },
48
+ next: { type: 'string', description: 'What would move this row, or "—" when it is settled.' },
49
+ },
50
+ required: ['n', 'band', 'source', 'next'],
51
+ },
52
+ },
53
+ asking_now: {
54
+ type: ['integer', 'null'],
55
+ minimum: 1,
56
+ maximum: 8,
57
+ description: 'The dimension being asked about right now, or null when nothing is (the twin step asks nothing).',
58
+ },
59
+ budget: {
60
+ type: ['object', 'null'],
61
+ description: 'Interview budget: {asked, limit} during the brief phase, {elapsed, estimate} during the informed ' +
62
+ 'interview, null for the twin step (which prints no budget line).',
63
+ },
64
+ gate: {
65
+ type: 'string',
66
+ enum: ['research-ready', 'decide-ready'],
67
+ description: 'Which gate the block is measured against. Selects the threshold marker the page draws on every meter: ' +
68
+ 'the brief runs against research-ready, the twin and informed interview against decide-ready.',
69
+ },
70
+ short: {
71
+ type: 'array',
72
+ items: { type: 'integer', minimum: 1, maximum: 8 },
73
+ description: 'Dimension numbers still below the gate, ascending and distinct. Empty means the gate is met.',
74
+ },
75
+ },
76
+ required: ['rows', 'gate', 'short'],
77
+ },
78
+ // Copresence Protocol §5.1 (copresence-protocol-v0.1, mukadra repo) —
79
+ // session-targeting capability. `enactment` names the worker allow-list entry
80
+ // this tool submits. Reversible: the panel is display-only, the next block
81
+ // replaces this one, and nothing is persisted.
82
+ _meta: {
83
+ copresence: {
84
+ target: 'session',
85
+ tier: 'reversible',
86
+ consent: 'implicit',
87
+ entitlement: 'free',
88
+ visibility: 'advertised',
89
+ enactment: 'intake.setSufficiency',
90
+ },
91
+ },
92
+ };
93
+ export async function handleSetSufficiency(args) {
94
+ if (!Array.isArray(args['rows'])) {
95
+ throw new McpError(ErrorCode.InvalidParams, 'rows must be an array of the eight dimension rows');
96
+ }
97
+ if (typeof args['gate'] !== 'string' || !args['gate']) {
98
+ throw new McpError(ErrorCode.InvalidParams, 'gate must be "research-ready" or "decide-ready"');
99
+ }
100
+ if (!Array.isArray(args['short'])) {
101
+ throw new McpError(ErrorCode.InvalidParams, 'short must be an array of dimension numbers (empty when the gate is met)');
102
+ }
103
+ const pairId = getLinkedPairId();
104
+ if (!pairId) {
105
+ 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.');
106
+ }
107
+ // The WHOLE argument object goes on the wire as one block. Producer fields
108
+ // the page does not read (interview_id, phase, gate_met) ride along
109
+ // deliberately: the worker ignores unknown keys rather than rejecting them,
110
+ // so a new producer field is never a breaking change across the two graphs.
111
+ const res = await postWorkerApi('/tenant/command', {
112
+ pair_id: pairId,
113
+ capability: 'intake',
114
+ method: 'setSufficiency',
115
+ args: [args],
116
+ });
117
+ if (res.status === 404) {
118
+ throw new McpError(ErrorCode.InvalidParams, 'Linked session not found or expired. Re-link with link_session.');
119
+ }
120
+ // The allow-list rejects with 400 ("Command not allowed: <defect>") — the
121
+ // worker's own status for a refused command. 422 is mapped alongside it for
122
+ // parity with the other session verbs, whose 422 branch predates this one.
123
+ // Surfacing the worker's text matters more here than for a scalar verb: the
124
+ // rejection names which row and which token was wrong.
125
+ if (res.status === 400 || res.status === 422) {
126
+ const body = (await res.json().catch(() => ({})));
127
+ throw new McpError(ErrorCode.InvalidParams, body.error ?? 'Sufficiency block rejected by the allow-list.');
128
+ }
129
+ if (res.status === 409) {
130
+ throw new McpError(ErrorCode.InvalidParams, 'No live tab detected. Open runsnative.org, ensure the tab is active, then retry.');
131
+ }
132
+ if (!res.ok) {
133
+ const body = await res.text().catch(() => '');
134
+ throw new McpError(ErrorCode.InternalError, `Worker error ${res.status}: ${body}`);
135
+ }
136
+ const short = args['short'];
137
+ const status = short.length === 0 ? 'the gate is met' : `still short on ${short.join(', ')}`;
138
+ return {
139
+ content: [
140
+ {
141
+ type: 'text',
142
+ text: `Sufficiency block enqueued (${args['gate']}, ${status}). The panel will redraw within one poll interval.`,
143
+ },
144
+ ],
145
+ };
146
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runsnative/mcp-server",
3
- "version": "0.10.0",
3
+ "version": "0.13.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist/"