@runsnative/mcp-server 0.9.1 → 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
@@ -3,10 +3,24 @@ let _linkedPairId = null;
3
3
  export function getLinkedPairId() { return _linkedPairId; }
4
4
  export function setLinkedPairId(id) { _linkedPairId = id; }
5
5
  export function clearLinkedPairId() { _linkedPairId = null; }
6
+ /**
7
+ * Read an env var without assuming `process` exists (JUNE-1297).
8
+ *
9
+ * This module is reachable from surfaces that are NOT Node: the mcp-api-worker
10
+ * remote MCP endpoint binds the same `SESSION_ENACT_TOOLS` array the stdio
11
+ * server registers from — deliberately, so the two transports cannot advertise
12
+ * different verbs — and importing that array pulls this module in with it. A
13
+ * bare `process.env` at module scope would throw a ReferenceError while that
14
+ * graph loads on a runtime without `process`, taking the whole Worker down at
15
+ * boot, for values the Worker never reads. Behaviour under Node is unchanged.
16
+ */
17
+ function readEnv(name) {
18
+ return typeof process === 'undefined' ? undefined : process.env[name];
19
+ }
6
20
  // trim(): trailing whitespace from setx/cmd wrappers silently corrupts the
7
21
  // Authorization header (KUKAMANGA-wide env-read rule).
8
- const BASE_URL = (process.env['RUNSNATIVE_API_URL'] ?? 'https://api.runsnative.org/mcp').trim();
9
- const TOKEN = process.env['RUNSNATIVE_TENANT_TOKEN']?.trim();
22
+ const BASE_URL = (readEnv('RUNSNATIVE_API_URL') ?? 'https://api.runsnative.org/mcp').trim();
23
+ const TOKEN = readEnv('RUNSNATIVE_TENANT_TOKEN')?.trim();
10
24
  // Every worker path below is appended to BASE_URL verbatim ('/content',
11
25
  // '/pair/start', …), so BASE_URL must carry the '/mcp' prefix — which the default
12
26
  // above does. Pointing RUNSNATIVE_API_URL at the bare origin
@@ -18,7 +32,7 @@ const TOKEN = process.env['RUNSNATIVE_TENANT_TOKEN']?.trim();
18
32
  // set the bare origin, so a correctly-installed server failed every write and the
19
33
  // error gave no way to see why. Warn at startup instead of 404-ing silently later.
20
34
  // stderr only — stdout carries the MCP JSON-RPC frames and must not be polluted.
21
- if (process.env['RUNSNATIVE_API_URL'] && !/\/mcp\/?$/.test(BASE_URL)) {
35
+ if (readEnv('RUNSNATIVE_API_URL') && !/\/mcp\/?$/.test(BASE_URL)) {
22
36
  process.stderr.write(`[runsnative-mcp] WARNING: RUNSNATIVE_API_URL is "${BASE_URL}", which does not end in "/mcp".\n` +
23
37
  `[runsnative-mcp] Worker requests will resolve to "${BASE_URL}/content" and 404.\n` +
24
38
  `[runsnative-mcp] Use "https://api.runsnative.org/mcp", or unset the variable to take the default.\n`);
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
@@ -19,6 +19,10 @@ import { GET_COMPLETENESS_MAP_TOOL, handleGetCompletenessMap } from './tools/get
19
19
  import { LINK_SESSION_TOOL, handleLinkSession } from './tools/link-session.js';
20
20
  import { SET_SITE_CONTENT_TOOL, handleSetSiteContent } from './tools/set-site-content.js';
21
21
  import { PROMOTE_SITE_CONTENT_TOOL, handlePromoteSiteContent } from './tools/promote-site-content.js';
22
+ import { ORDER_SITE_TOOL, handleOrderSite } from './tools/order-site.js';
23
+ import { ORDER_CUSTOM_DOMAIN_TOOL, handleOrderCustomDomain } from './tools/order-custom-domain.js';
24
+ import { LIST_ORDERS_TOOL, handleListOrders } from './tools/list-orders.js';
25
+ import { SELECT_DOMAIN_CANDIDATE_TOOL, handleSelectDomainCandidate } from './tools/select-domain-candidate.js';
22
26
  import { handleSetTheme } from './tools/set-theme.js';
23
27
  import { handleSetSkin } from './tools/set-skin.js';
24
28
  import { handleSetMode } from './tools/set-mode.js';
@@ -28,6 +32,7 @@ import { handleNavigate } from './tools/navigate.js';
28
32
  import { handleSetInstanceVariant } from './tools/set-instance-variant.js';
29
33
  import { handleSpotlight } from './tools/spotlight.js';
30
34
  import { handleResolveTarget } from './tools/resolve-target.js';
35
+ import { handleSetSufficiency } from './tools/set-sufficiency.js';
31
36
  // Session-enact tools register via the single-source list (JUNE-739) so the
32
37
  // registration surface and the reconciliation tests read the same array.
33
38
  import { SESSION_ENACT_TOOLS } from './tools/session-tools.js';
@@ -77,6 +82,10 @@ export async function createRunsnativeServer() {
77
82
  LINK_SESSION_TOOL,
78
83
  SET_SITE_CONTENT_TOOL,
79
84
  PROMOTE_SITE_CONTENT_TOOL,
85
+ ORDER_SITE_TOOL,
86
+ ORDER_CUSTOM_DOMAIN_TOOL,
87
+ LIST_ORDERS_TOOL,
88
+ SELECT_DOMAIN_CANDIDATE_TOOL,
80
89
  ...SESSION_ENACT_TOOLS,
81
90
  GET_MARKER_CAPTURE_TOOL,
82
91
  RENDER_MARKER_CAPTURE_TOOL,
@@ -121,6 +130,14 @@ export async function createRunsnativeServer() {
121
130
  return handleSetSiteContent(request.params.arguments ?? {});
122
131
  case 'promote_site_content':
123
132
  return handlePromoteSiteContent(request.params.arguments ?? {});
133
+ case 'order_site':
134
+ return handleOrderSite(request.params.arguments ?? {});
135
+ case 'order_custom_domain':
136
+ return handleOrderCustomDomain(request.params.arguments ?? {});
137
+ case 'list_orders':
138
+ return handleListOrders();
139
+ case 'select_domain_candidate':
140
+ return handleSelectDomainCandidate(request.params.arguments ?? {});
124
141
  case 'set_theme':
125
142
  return handleSetTheme(request.params.arguments ?? {});
126
143
  case 'set_skin':
@@ -139,6 +156,8 @@ export async function createRunsnativeServer() {
139
156
  return handleSpotlight(request.params.arguments ?? {});
140
157
  case 'resolve_target':
141
158
  return handleResolveTarget(request.params.arguments ?? {});
159
+ case 'set_sufficiency':
160
+ return handleSetSufficiency(request.params.arguments ?? {});
142
161
  case 'get_marker_capture':
143
162
  return handleGetMarkerCapture(request.params.arguments ?? {});
144
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: [],
@@ -0,0 +1,47 @@
1
+ import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
2
+ import { getWorkerApi } from '../bridge-client.js';
3
+ // Ordinary MCP tool — the read leg of the order desk (JUNE-1403 / JUNE-1403.1).
4
+ // Ungated on the worker side by design (GET /mcp/tenant/orders is a read of
5
+ // the caller's own orders, not a spend), so it carries no _meta.copresence
6
+ // and needs no role beyond a valid token.
7
+ export const LIST_ORDERS_TOOL = {
8
+ name: 'list_orders',
9
+ description: "List the account's own orders (site and custom-domain), newest first. Use this to check progress on " +
10
+ 'an order placed with order_site or order_custom_domain, or to see whether a custom-domain order has ' +
11
+ 'reached "awaiting-selection" and needs select_domain_candidate. Requires an authenticated RunsNative ' +
12
+ 'token; any role may read its own orders.',
13
+ inputSchema: {
14
+ type: 'object',
15
+ properties: {},
16
+ required: [],
17
+ },
18
+ };
19
+ export async function handleListOrders() {
20
+ const res = await getWorkerApi('/tenant/orders');
21
+ if (res.status === 401) {
22
+ throw new McpError(ErrorCode.InvalidParams, 'Not authenticated. Set RUNSNATIVE_TENANT_TOKEN to a valid token.');
23
+ }
24
+ if (!res.ok) {
25
+ const body = await res.text().catch(() => '');
26
+ throw new McpError(ErrorCode.InternalError, `Worker error ${res.status}: ${body}`);
27
+ }
28
+ const body = (await res.json());
29
+ if (body.orders.length === 0) {
30
+ return { content: [{ type: 'text', text: 'No orders on this account yet.' }] };
31
+ }
32
+ const lines = body.orders.map((o) => {
33
+ const subject = o.sku === 'create-site'
34
+ ? `site "${o.siteSlug}"`
35
+ : `custom domain [${(o.requestedCandidates ?? []).join(', ')}]${o.selectedCandidate ? ` → selected ${o.selectedCandidate}` : ''}`;
36
+ const err = o.error ? ` — error: ${o.error}` : '';
37
+ return `- ${o.id}: ${subject}, status: ${o.status}, production: ${o.production}${err}`;
38
+ });
39
+ return {
40
+ content: [
41
+ {
42
+ type: 'text',
43
+ text: `${body.orders.length} order(s):\n${lines.join('\n')}`,
44
+ },
45
+ ],
46
+ };
47
+ }
@@ -0,0 +1,96 @@
1
+ import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
2
+ import { postWorkerApi } from '../bridge-client.js';
3
+ import { refusalMessage } from './order-site.js';
4
+ // Ordinary MCP tool — see order-site.ts for the shared rationale
5
+ // (JUNE-1403 / JUNE-1403.1). Sibling SKU on the same POST /tenant/orders
6
+ // route; shares order-site.ts's refusalMessage for the identical 402 shape.
7
+ export const ORDER_CUSTOM_DOMAIN_TOOL = {
8
+ name: 'order_custom_domain',
9
+ description: "Order a custom domain for the user's site. This consumes one order credit from the account — tell " +
10
+ "the user before calling it. Supply EXACTLY ONE of a concrete name (requestedName) or the customer's " +
11
+ 'own candidate ideas (candidates) — never both. Availability is never a purchase guarantee: the order ' +
12
+ 'consumes its credit and scores the candidate(s) server-side, but a candidate can still end up ' +
13
+ 'unregistrable. Domain names are proposed, never auto-selected — once the order reaches ' +
14
+ '"awaiting-selection", call select_domain_candidate with the customer\'s pick. Poll progress with ' +
15
+ 'list_orders. Requires an authenticated RunsNative token whose role may place orders (site:order).',
16
+ inputSchema: {
17
+ type: 'object',
18
+ properties: {
19
+ requestedName: {
20
+ type: 'string',
21
+ description: 'A single concrete domain name to order (e.g. "acme-bakery.com"). Mutually exclusive with candidates.',
22
+ },
23
+ candidates: {
24
+ type: 'array',
25
+ items: { type: 'string' },
26
+ description: "The customer's own hand-typed candidate domain names, scored server-side. Mutually exclusive " +
27
+ 'with requestedName.',
28
+ },
29
+ constraints: {
30
+ type: 'object',
31
+ description: 'Optional scoring/refusal constraints applied to each candidate: tlds (string[]), avoid ' +
32
+ '(string[]), maxSyllables (number). These never invent a name — they only modulate scoring.',
33
+ properties: {
34
+ tlds: { type: 'array', items: { type: 'string' } },
35
+ avoid: { type: 'array', items: { type: 'string' } },
36
+ maxSyllables: { type: 'number' },
37
+ },
38
+ },
39
+ },
40
+ required: [],
41
+ },
42
+ };
43
+ export async function handleOrderCustomDomain(args) {
44
+ const requestedName = args['requestedName'];
45
+ const candidates = args['candidates'];
46
+ const haveName = requestedName !== undefined && requestedName !== null;
47
+ const haveList = candidates !== undefined && candidates !== null;
48
+ if (haveName === haveList) {
49
+ throw new McpError(ErrorCode.InvalidParams, 'Supply exactly one of requestedName (a string) or candidates (a non-empty array), not both or neither.');
50
+ }
51
+ if (haveName && typeof requestedName !== 'string') {
52
+ throw new McpError(ErrorCode.InvalidParams, 'requestedName must be a string');
53
+ }
54
+ if (haveList && !Array.isArray(candidates)) {
55
+ throw new McpError(ErrorCode.InvalidParams, 'candidates must be an array of strings');
56
+ }
57
+ const body = { sku: 'custom-domain' };
58
+ if (haveName)
59
+ body['requestedName'] = requestedName;
60
+ if (haveList)
61
+ body['candidates'] = candidates;
62
+ const constraints = args['constraints'];
63
+ if (constraints !== undefined && constraints !== null)
64
+ body['constraints'] = constraints;
65
+ const res = await postWorkerApi('/tenant/orders', body);
66
+ if (res.status === 401) {
67
+ throw new McpError(ErrorCode.InvalidParams, 'Not authenticated. Set RUNSNATIVE_TENANT_TOKEN to a token that may place orders.');
68
+ }
69
+ if (res.status === 403) {
70
+ throw new McpError(ErrorCode.InvalidParams, 'Your role does not permit placing orders for this account.');
71
+ }
72
+ if (res.status === 402) {
73
+ throw new McpError(ErrorCode.InvalidParams, await refusalMessage(res));
74
+ }
75
+ if (res.status === 400) {
76
+ const errBody = (await res.json().catch(() => ({})));
77
+ throw new McpError(ErrorCode.InvalidParams, errBody.error ?? 'Order rejected by the server.');
78
+ }
79
+ if (!res.ok) {
80
+ const errBody = await res.text().catch(() => '');
81
+ throw new McpError(ErrorCode.InternalError, `Worker error ${res.status}: ${errBody}`);
82
+ }
83
+ const resBody = (await res.json());
84
+ const { order, created } = resBody;
85
+ const verb = created ? 'Ordered a custom domain from candidates' : 'Already have a pending custom-domain order for candidates';
86
+ return {
87
+ content: [
88
+ {
89
+ type: 'text',
90
+ text: `${verb} [${(order.requestedCandidates ?? []).join(', ')}] (order ${order.id}, status: ${order.status}). ` +
91
+ 'Poll list_orders for scoring progress — availability is never a purchase guarantee, and once ' +
92
+ 'candidates are scored the customer must pick one with select_domain_candidate.',
93
+ },
94
+ ],
95
+ };
96
+ }
@@ -0,0 +1,85 @@
1
+ import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
2
+ import { postWorkerApi } from '../bridge-client.js';
3
+ // Ordinary MCP tool (JUNE-1403, surface corrected from the original ticket
4
+ // text by the readiness pass JUNE-1403.1): placing an order is an
5
+ // account/commerce action, not something enacted on a bound live session, so
6
+ // per Copresence Protocol §5 it carries no `_meta.copresence` and is not in
7
+ // SESSION_ENACT_TOOLS — same posture as set_site_content / promote_site_content.
8
+ export const ORDER_SITE_TOOL = {
9
+ name: 'order_site',
10
+ description: "Order a new RunsNative site for the user's account (the \"create my site\" action). " +
11
+ 'This consumes one order credit from the account — tell the user before calling it. A fulfilled order ' +
12
+ 'attaches a preview-only site (production is NOT activated by this call — that is a separate, later ' +
13
+ 'step) reachable at the address named in the response. Calling this again while an order for a new ' +
14
+ 'site is still pending/building returns that SAME order rather than starting a second one — it is safe ' +
15
+ 'to retry. Poll progress with list_orders. Requires an authenticated RunsNative token whose role may ' +
16
+ 'place orders (site:order).',
17
+ inputSchema: {
18
+ type: 'object',
19
+ properties: {
20
+ siteSlug: {
21
+ type: 'string',
22
+ description: 'The slug for the new site: 3-40 characters, lowercase a-z, 0-9 and hyphens, starting and ' +
23
+ 'ending alphanumeric (e.g. "acme-bakery").',
24
+ },
25
+ },
26
+ required: ['siteSlug'],
27
+ },
28
+ };
29
+ export async function handleOrderSite(args) {
30
+ const siteSlug = args['siteSlug'];
31
+ if (typeof siteSlug !== 'string' || !siteSlug.trim()) {
32
+ throw new McpError(ErrorCode.InvalidParams, 'siteSlug must be a non-empty string');
33
+ }
34
+ const res = await postWorkerApi('/tenant/orders', { sku: 'create-site', siteSlug: siteSlug.trim() });
35
+ if (res.status === 401) {
36
+ throw new McpError(ErrorCode.InvalidParams, 'Not authenticated. Set RUNSNATIVE_TENANT_TOKEN to a token that may place orders.');
37
+ }
38
+ if (res.status === 403) {
39
+ throw new McpError(ErrorCode.InvalidParams, 'Your role does not permit placing orders for this account.');
40
+ }
41
+ if (res.status === 402) {
42
+ throw new McpError(ErrorCode.InvalidParams, await refusalMessage(res));
43
+ }
44
+ if (res.status === 400) {
45
+ const body = (await res.json().catch(() => ({})));
46
+ throw new McpError(ErrorCode.InvalidParams, body.error ?? 'Order rejected by the server.');
47
+ }
48
+ if (!res.ok) {
49
+ const body = await res.text().catch(() => '');
50
+ throw new McpError(ErrorCode.InternalError, `Worker error ${res.status}: ${body}`);
51
+ }
52
+ const body = (await res.json());
53
+ const { order, created } = body;
54
+ const verb = created ? 'Ordered' : 'Already have a pending order for';
55
+ return {
56
+ content: [
57
+ {
58
+ type: 'text',
59
+ text: `${verb} site "${order.siteSlug}" (order ${order.id}, status: ${order.status}, ` +
60
+ `production: ${order.production}). Poll list_orders for progress — the site stays preview-only ` +
61
+ 'until it is separately activated.',
62
+ },
63
+ ],
64
+ };
65
+ }
66
+ /**
67
+ * JUNE-1404 D0-D5's three refusal reasons, distinct conversion paths per
68
+ * §8.2 — surface the reason and payment_url rather than a generic failure
69
+ * (the build prompt's stated defect to avoid). Shared verbatim by
70
+ * order-custom-domain.ts, the sibling SKU on the same route.
71
+ */
72
+ export async function refusalMessage(res) {
73
+ const body = (await res.json().catch(() => ({})));
74
+ const url = body.payment_url ? `: ${body.payment_url}` : '.';
75
+ if (body.reason === 'renewal_required') {
76
+ return `The account's order grant has lapsed — renew to keep ordering${url}`;
77
+ }
78
+ if (body.reason === 'quota_exhausted') {
79
+ const used = typeof body.used === 'number' && typeof body.quota_limit === 'number'
80
+ ? ` (${body.used}/${body.quota_limit} used)`
81
+ : '';
82
+ return `Order quota exhausted${used} — upgrade to order more${url}`;
83
+ }
84
+ return `Ordering is a paid capability this account doesn't hold yet — upgrade to unlock it${url}`;
85
+ }
@@ -0,0 +1,67 @@
1
+ import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
2
+ import { postWorkerApi } from '../bridge-client.js';
3
+ // Ordinary MCP tool — the human-curation step of a custom-domain order
4
+ // (JUNE-1112, discoverability leg JUNE-1403 / JUNE-1403.1). Deliberately a
5
+ // SEPARATE act from order_custom_domain: candidates are proposed, never
6
+ // auto-selected, so this always needs the customer's own pick.
7
+ export const SELECT_DOMAIN_CANDIDATE_TOOL = {
8
+ name: 'select_domain_candidate',
9
+ description: 'Pick which scored domain candidate to buy for a custom-domain order that has reached ' +
10
+ '"awaiting-selection" (check with list_orders). This is a separate, human-directed act from placing ' +
11
+ 'the order — never call this without the customer having chosen the name themselves. The candidate ' +
12
+ 'must be one of the names already scored for this order.',
13
+ inputSchema: {
14
+ type: 'object',
15
+ properties: {
16
+ orderId: {
17
+ type: 'string',
18
+ description: 'The id of the custom-domain order (from order_custom_domain or list_orders).',
19
+ },
20
+ candidate: {
21
+ type: 'string',
22
+ description: 'The domain name the customer picked, exactly as scored for this order.',
23
+ },
24
+ },
25
+ required: ['orderId', 'candidate'],
26
+ },
27
+ };
28
+ export async function handleSelectDomainCandidate(args) {
29
+ const orderId = args['orderId'];
30
+ if (typeof orderId !== 'string' || !orderId.trim()) {
31
+ throw new McpError(ErrorCode.InvalidParams, 'orderId must be a non-empty string');
32
+ }
33
+ const candidate = args['candidate'];
34
+ if (typeof candidate !== 'string' || !candidate.trim()) {
35
+ throw new McpError(ErrorCode.InvalidParams, 'candidate must be a non-empty string');
36
+ }
37
+ const res = await postWorkerApi(`/tenant/orders/${encodeURIComponent(orderId.trim())}/select`, {
38
+ candidate: candidate.trim(),
39
+ });
40
+ if (res.status === 401) {
41
+ throw new McpError(ErrorCode.InvalidParams, 'Not authenticated. Set RUNSNATIVE_TENANT_TOKEN to a token that may place orders.');
42
+ }
43
+ if (res.status === 403) {
44
+ throw new McpError(ErrorCode.InvalidParams, 'Your role does not permit selecting a candidate for this account.');
45
+ }
46
+ if (res.status === 404) {
47
+ throw new McpError(ErrorCode.InvalidParams, 'No such order on this account.');
48
+ }
49
+ if (res.status === 400) {
50
+ const body = (await res.json().catch(() => ({})));
51
+ throw new McpError(ErrorCode.InvalidParams, body.error ?? 'Selection rejected by the server.');
52
+ }
53
+ if (!res.ok) {
54
+ const body = await res.text().catch(() => '');
55
+ throw new McpError(ErrorCode.InternalError, `Worker error ${res.status}: ${body}`);
56
+ }
57
+ const body = (await res.json());
58
+ return {
59
+ content: [
60
+ {
61
+ type: 'text',
62
+ text: `Selected "${body.order.selectedCandidate}" for order ${body.order.id} (status: ${body.order.status}). ` +
63
+ 'Poll list_orders for registration/point progress.',
64
+ },
65
+ ],
66
+ };
67
+ }
@@ -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.9.1",
3
+ "version": "0.13.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist/"