@runsnative/mcp-server 0.9.1 → 0.10.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.
@@ -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/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';
@@ -77,6 +81,10 @@ export async function createRunsnativeServer() {
77
81
  LINK_SESSION_TOOL,
78
82
  SET_SITE_CONTENT_TOOL,
79
83
  PROMOTE_SITE_CONTENT_TOOL,
84
+ ORDER_SITE_TOOL,
85
+ ORDER_CUSTOM_DOMAIN_TOOL,
86
+ LIST_ORDERS_TOOL,
87
+ SELECT_DOMAIN_CANDIDATE_TOOL,
80
88
  ...SESSION_ENACT_TOOLS,
81
89
  GET_MARKER_CAPTURE_TOOL,
82
90
  RENDER_MARKER_CAPTURE_TOOL,
@@ -121,6 +129,14 @@ export async function createRunsnativeServer() {
121
129
  return handleSetSiteContent(request.params.arguments ?? {});
122
130
  case 'promote_site_content':
123
131
  return handlePromoteSiteContent(request.params.arguments ?? {});
132
+ case 'order_site':
133
+ return handleOrderSite(request.params.arguments ?? {});
134
+ case 'order_custom_domain':
135
+ return handleOrderCustomDomain(request.params.arguments ?? {});
136
+ case 'list_orders':
137
+ return handleListOrders();
138
+ case 'select_domain_candidate':
139
+ return handleSelectDomainCandidate(request.params.arguments ?? {});
124
140
  case 'set_theme':
125
141
  return handleSetTheme(request.params.arguments ?? {});
126
142
  case 'set_skin':
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runsnative/mcp-server",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist/"