@checkbox-site/mcp 0.1.1 → 0.2.1

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
@@ -7,7 +7,7 @@ your agent can set up a project with one human click, check checkout code before
7
7
  done and run synthetic test subscriptions — against ROSCA, California's Automatic Renewal Law, New York GBL § 527-a
8
8
  and the other state automatic-renewal laws.
9
9
 
10
- Docs: https://checkbox.site/docs/mcp · Agent index: https://checkbox.site/llms.txt · Free to start.
10
+ Docs: https://checkbox.site/docs/mcp · Agent index: https://checkbox.site/llms.txt · Free. Every feature. No credit card. Fair-use limits.
11
11
 
12
12
  ## Connect
13
13
 
@@ -45,7 +45,7 @@ Package: `@checkbox-site/mcp` on npm (Node 20+; binary `checkbox-mcp`). Options:
45
45
 
46
46
  ## Tools
47
47
 
48
- Start with `setup_checkbox`; then `get_compliance_plan` → apply a task → `verify_task`, until the plan is compliant.
48
+ Start with `setup_checkbox`; then `get_compliance_plan` → apply a task → `verify_task`, until the checks pass (plan status `compliant`).
49
49
  Preflight tools (`check_disclosure`, `check_stripe_checkout_session`, `check_email`, `check_page_html`,
50
50
  `get_notice_schedule`) work on code before it is deployed. The full list, with the API operation behind each tool, is
51
51
  at https://checkbox.site/docs/mcp#tools.
@@ -56,7 +56,7 @@ the next tool to call.
56
56
 
57
57
  ## Not legal advice
58
58
 
59
- Checkbox output is an automated assessment against a legal catalog pending attorney review; it is not legal advice.
59
+ Rules drafted from the statute text and updated as new statutes come into effect. Automated checks, not legal advice. Every rule cites its source, so you or your lawyer can check it.
60
60
 
61
61
  ## License
62
62
 
package/dist/api.d.ts CHANGED
@@ -20,6 +20,8 @@ export interface NextAction {
20
20
  endpoint?: string | null;
21
21
  url?: string | null;
22
22
  task?: string | null;
23
+ /** The MCP tool the API maps operation_id to (`call_api` when there is none). */
24
+ mcp_tool?: string | null;
23
25
  }
24
26
  /** A non-2xx /v1 response (the error envelope) or a network failure (status 0). */
25
27
  export declare class CheckboxApiError extends Error {
@@ -39,6 +41,11 @@ export interface CheckboxApi {
39
41
  readonly hasKey: boolean;
40
42
  /** Base URL of the API (for links in tool output). */
41
43
  readonly apiBase: string;
44
+ /**
45
+ * Round 21 (pilot 20 item 5): the oldest MCP server version the API said it expects (response header
46
+ * checkbox-mcp-minimum-version), once a response carried it. Absent on in-process clients (same version as the API).
47
+ */
48
+ minimumMcpVersion?(): string | undefined;
42
49
  }
43
50
  /** Builds the query string for a request (`expand` arrays are not needed by the tools). */
44
51
  export declare function withQuery(path: string, query?: ApiRequestOptions['query']): string;
package/dist/api.js CHANGED
@@ -6,6 +6,7 @@
6
6
  // - The local stdio server (`checkbox-mcp`) implements it over HTTPS (createHttpApi, or an @checkbox-site/node client
7
7
  // through fromSdkClient).
8
8
  import { randomUUID } from 'node:crypto';
9
+ import { MinimumVersionTracker } from "./version-check.js";
9
10
  /** API version the MCP tools are written against (sent as Checkbox-Version). */
10
11
  export const API_VERSION = '2026-10-30';
11
12
  export const DEFAULT_API_BASE = 'https://app.checkbox.site';
@@ -57,9 +58,11 @@ export function createHttpApi(opts) {
57
58
  const f = opts.fetch ?? fetch;
58
59
  const base = (opts.apiBase ?? DEFAULT_API_BASE).replace(/\/+$/, '');
59
60
  const maxRetries = opts.maxRetries ?? 2;
61
+ const versions = new MinimumVersionTracker();
60
62
  return {
61
63
  hasKey: Boolean(opts.apiKey),
62
64
  apiBase: base,
65
+ minimumMcpVersion: () => versions.minimum,
63
66
  async request(method, path, ro = {}) {
64
67
  const url = base + withQuery(path.startsWith('/') ? path : `/${path}`, ro.query);
65
68
  const headers = {
@@ -95,6 +98,7 @@ export function createHttpApi(opts) {
95
98
  await sleep(Number.isFinite(retryAfter) && retryAfter > 0 ? Math.min(retryAfter, 10) * 1000 : 500 * 2 ** attempt);
96
99
  continue;
97
100
  }
101
+ versions.observe(res.headers);
98
102
  const text = await res.text();
99
103
  let json;
100
104
  try {
@@ -122,13 +126,19 @@ export function fromSdkClient(client, info) {
122
126
  : null;
123
127
  if (!call)
124
128
  return null;
129
+ const versions = new MinimumVersionTracker();
125
130
  return {
126
131
  ...info,
132
+ minimumMcpVersion: () => versions.minimum,
127
133
  async request(method, path, ro = {}) {
128
134
  try {
129
- return (await call(method, path, ro));
135
+ const out = await call(method, path, ro);
136
+ // The Node SDK puts the response headers on a non-enumerable `lastResponse`.
137
+ versions.observe(out?.lastResponse?.headers);
138
+ return out;
130
139
  }
131
140
  catch (err) {
141
+ versions.observe(err?.headers);
132
142
  throw toCheckboxApiError(err);
133
143
  }
134
144
  },
package/dist/format.d.ts CHANGED
@@ -10,8 +10,30 @@ export declare function mcpToolFor(operationId: string | null | undefined): stri
10
10
  * reads it as an instruction next to the "Next:" line. Full values stay in the fenced JSON.
11
11
  */
12
12
  export declare function untrusted(value: unknown, max?: number): string;
13
+ /**
14
+ * Whether the `checkbox` CLI (@checkbox-site/cli) is on npm. While this is false, MCP output never shows `command`
15
+ * fields or a "CLI equivalent". It is published.
16
+ */
17
+ export declare const CLI_PUBLISHED = true;
18
+ /** Docs for `checkbox ci` / the GitHub Action (checkbox.json + .github/workflows/checkbox.yml). */
19
+ export declare const CI_DOCS_URL = "https://checkbox.site/docs/guides/ci";
20
+ /**
21
+ * After a check passes, one line offering to keep it passing in CI. The Action runs the published CLI, so this stays
22
+ * off until CLI_PUBLISHED is true (`published` is for tests).
23
+ */
24
+ export declare function ciHint(passing: boolean, published?: boolean): string | null;
25
+ /**
26
+ * Audit P2: text the API writes for SDK users names operations (verifyTask, updateSite, …) and REST paths. Over MCP
27
+ * those become the tool to call: a dedicated tool name, else `call_api` with the endpoint. CLI hints are dropped while
28
+ * the CLI is unpublished.
29
+ */
30
+ export declare function mcpText(text: string): string;
31
+ /** Rewrites agent-facing strings in an API object for MCP (mcpText), and drops `command` while the CLI is unpublished. */
32
+ export declare function forMcp(value: unknown, depth?: number): unknown;
13
33
  /** One line telling the model what to call next, in MCP terms. */
14
- export declare function describeNextAction(next: NextAction | null | undefined): string | null;
34
+ export declare function describeNextAction(next: NextAction | null | undefined, ctx?: {
35
+ data?: unknown;
36
+ }): string | null;
15
37
  /** Pulls the top-level next_action off an API object. */
16
38
  export declare function nextActionOf(obj: unknown): NextAction | null;
17
39
  export interface ToolOutput {
@@ -35,6 +57,9 @@ export declare function fitJson(data: unknown, max?: number): {
35
57
  json: string;
36
58
  page?: string;
37
59
  };
60
+ /** The "Next:" line of a result whose API object names no next step. */
61
+ export declare const FALLBACK_NEXT = "Next: nothing more is needed for this result; get_compliance_plan names the site's next step.";
62
+ export declare const FALLBACK_NEXT_ERROR = "Next: fix what the error names (see the hint above, if any) and call the tool again.";
38
63
  export declare function renderOutput(out: ToolOutput): CallToolResult;
39
64
  /** An API error as a tool result: stable code, message, param and the API's own next_action. */
40
65
  export declare function renderError(err: unknown): CallToolResult;
package/dist/format.js CHANGED
@@ -15,6 +15,7 @@ export const OPERATION_TOOLS = {
15
15
  answerTask: 'answer_task',
16
16
  verifySite: 'verify_site',
17
17
  retrieveVerification: 'get_verification',
18
+ createTestCredential: 'store_stripe_test_key',
18
19
  createTestRun: 'start_test_run',
19
20
  retrieveTestRun: 'get_test_run',
20
21
  listTestRuns: 'get_test_run',
@@ -34,6 +35,7 @@ export const OPERATION_TOOLS = {
34
35
  listFindings: 'list_findings',
35
36
  retrieveFinding: 'get_finding',
36
37
  retrieveFindingFix: 'get_finding',
38
+ publishShopifyTerms: 'publish_shopify_terms',
37
39
  createScan: 'run_scan',
38
40
  retrieveScan: 'get_scan',
39
41
  listLawUpdates: 'list_law_updates',
@@ -74,6 +76,11 @@ const OPERATION_ENDPOINTS = {
74
76
  listSiteFixes: 'GET /v1/sites/{site}/fixes',
75
77
  listVerifications: 'GET /v1/sites/{site}/verifications',
76
78
  cancelTestRun: 'POST /v1/test_runs/{test_run}/cancel',
79
+ createReceiptToken: 'POST /v1/receipt_tokens',
80
+ createReceipt: 'POST /v1/receipts',
81
+ retrieveReceiptSettings: 'GET /v1/receipt_settings',
82
+ updateReceiptSettings: 'POST /v1/receipt_settings',
83
+ retrieveDueNotice: 'GET /v1/notices/{notice}',
77
84
  };
78
85
  /**
79
86
  * Quotes text Checkbox did not write (page HTML, email bodies, policy text, merchant-supplied strings) for the text part
@@ -88,23 +95,125 @@ export function untrusted(value, max = 80) {
88
95
  s = s.replace(/</g, '‹').replace(/>/g, '›');
89
96
  return `<untrusted_text>${JSON.stringify(s)}</untrusted_text>`;
90
97
  }
98
+ /**
99
+ * Whether the `checkbox` CLI (@checkbox-site/cli) is on npm. While this is false, MCP output never shows `command`
100
+ * fields or a "CLI equivalent". It is published.
101
+ */
102
+ export const CLI_PUBLISHED = true;
103
+ /** Docs for `checkbox ci` / the GitHub Action (checkbox.json + .github/workflows/checkbox.yml). */
104
+ export const CI_DOCS_URL = 'https://checkbox.site/docs/guides/ci';
105
+ /**
106
+ * After a check passes, one line offering to keep it passing in CI. The Action runs the published CLI, so this stays
107
+ * off until CLI_PUBLISHED is true (`published` is for tests).
108
+ */
109
+ export function ciHint(passing, published = CLI_PUBLISHED) {
110
+ if (!published || !passing)
111
+ return null;
112
+ return `Optional, ask the human once: keep these checks passing on every pull request. With a yes, write checkbox.json listing the files you just checked (Stripe params via "source" or "module", disclosure / email template files, the plans) and .github/workflows/checkbox.yml (uses: checkbox-site/checkbox@v1; offline, no key), then run \`npx @checkbox-site/cli ci\` to confirm. \`npx @checkbox-site/cli ci init --yes\` drafts both. ${CI_DOCS_URL}`;
113
+ }
114
+ /** REST paths of the preflight checks → their MCP tools (for text written for API users). */
115
+ const CHECK_PATH_TOOLS = {
116
+ disclosure: 'check_disclosure',
117
+ stripe_checkout_session: 'check_stripe_checkout_session',
118
+ stripe_payment_link: 'call_api (POST /v1/checks/stripe_payment_link)',
119
+ email: 'check_email',
120
+ page: 'check_page_html',
121
+ notice_schedule: 'get_notice_schedule',
122
+ };
123
+ /** CLI commands → the MCP tool that does the same (for text that still names a command). */
124
+ const CLI_TOOLS = { plan: 'get_compliance_plan', fix: 'get_task', answer: 'answer_task', verify: 'verify_site', init: 'get_setup_status', check: 'check_disclosure' };
125
+ const OP_RE = new RegExp(`\\b(${[...Object.keys(OPERATION_TOOLS), ...Object.keys(OPERATION_ENDPOINTS)].sort((a, b) => b.length - a.length).join('|')})\\b`, 'g');
126
+ /**
127
+ * Audit P2: text the API writes for SDK users names operations (verifyTask, updateSite, …) and REST paths. Over MCP
128
+ * those become the tool to call: a dedicated tool name, else `call_api` with the endpoint. CLI hints are dropped while
129
+ * the CLI is unpublished.
130
+ */
131
+ export function mcpText(text) {
132
+ // Round 23 (pilot 22 item 9): the REST body `{ type: "stripe_test", secret_key }` is said as the MCP tool's own
133
+ // arguments (store_stripe_test_key takes { secret_key }; `type` is accepted but not needed).
134
+ let s = text.replace(/\{ ?type: ?"stripe_test", ?secret_key\b/g, '{ secret_key');
135
+ s = s.replace(/(?:POST )?\/v1\/checks\/(disclosure|stripe_checkout_session|stripe_payment_link|email|page|notice_schedule)\b/g, (_m, k) => CHECK_PATH_TOOLS[k]);
136
+ s = s.replace(OP_RE, (op) => {
137
+ // Round 16 (pilot 15, sveltekit): "verifySetupSession" became plain get_setup_status, which only reads the checklist,
138
+ // so the agent looped on "Snippet installed: pending". The re-check is get_setup_status with verify=true.
139
+ if (op === 'verifySetupSession')
140
+ return 'get_setup_status (verify=true)';
141
+ const tool = OPERATION_TOOLS[op];
142
+ if (tool)
143
+ return tool;
144
+ const endpoint = OPERATION_ENDPOINTS[op];
145
+ return endpoint ? `call_api (${endpoint})` : op;
146
+ });
147
+ // Agent audit round 2: "call_api (POST /v1/sites/{site}/notice_settings) (POST /v1/sites/site_…/notice_settings)":
148
+ // an operation followed by its own concrete endpoint is said once, with the concrete path.
149
+ s = s.replace(/call_api \(((?:GET|POST|DELETE) )[^()]*\) \(\1([^()]+)\)/g, (_m, verb, path) => `call_api (${verb}${path})`);
150
+ if (!CLI_PUBLISHED) {
151
+ s = s
152
+ .replace(/\s*\((?:or )?(?:run )?`(?:npx [^`]*)?checkbox [^`]*`[^)]*\)/g, '')
153
+ .replace(/,? or (?:run )?`checkbox [^`]*`/g, '')
154
+ .replace(/ \(`checkbox [^`]*` (?:writes|does) [^)]*\)/g, '')
155
+ .replace(/(?:run )?`checkbox ([a-z-]+)[^`]*`/g, (_m, cmd) => `\`${CLI_TOOLS[cmd] ?? 'get_compliance_plan'}\``);
156
+ }
157
+ return s;
158
+ }
159
+ const TEXT_KEYS = new Set(['message', 'description', 'summary', 'fix', 'note', 'how', 'detail', 'headline']);
160
+ /** Round 21 (pilot 20 item 16): lists of agent-facing sentences the API writes with operation names (REST words). */
161
+ const TEXT_LIST_KEYS = new Set(['plan_terms_conflicts', 'notes', 'steps']);
162
+ /** Rewrites agent-facing strings in an API object for MCP (mcpText), and drops `command` while the CLI is unpublished. */
163
+ export function forMcp(value, depth = 0) {
164
+ if (depth > 12 || value === null || typeof value !== 'object')
165
+ return value;
166
+ if (Array.isArray(value))
167
+ return value.map((v) => forMcp(v, depth + 1));
168
+ const out = {};
169
+ for (const [k, v] of Object.entries(value)) {
170
+ if (k === 'command' && !CLI_PUBLISHED && (v === null || typeof v === 'string'))
171
+ continue;
172
+ out[k] =
173
+ typeof v === 'string' && TEXT_KEYS.has(k)
174
+ ? mcpText(v)
175
+ : Array.isArray(v) && TEXT_LIST_KEYS.has(k)
176
+ ? v.map((x) => (typeof x === 'string' ? mcpText(x) : forMcp(x, depth + 1)))
177
+ : forMcp(v, depth + 1);
178
+ }
179
+ return out;
180
+ }
181
+ /** The argument hint for the tool a next step names (the right id for that tool). */
182
+ function argHint(tool, next, ctx) {
183
+ const d = (ctx.data ?? {});
184
+ if (tool === 'get_verification')
185
+ return d.object === 'verification' && typeof d.id === 'string' ? ` (verification: ${d.id})` : '';
186
+ if (tool === 'get_setup_status' && next.operation_id === 'verifySetupSession')
187
+ return ' (verify: true)';
188
+ const task = next.task ?? /\/v1\/tasks\/(task_[A-Za-z0-9]+)/.exec(next.endpoint ?? '')?.[1];
189
+ return task && ['get_task', 'verify_task', 'answer_task'].includes(tool) ? ` (task: ${task})` : '';
190
+ }
91
191
  /** One line telling the model what to call next, in MCP terms. */
92
- export function describeNextAction(next) {
192
+ export function describeNextAction(next, ctx = {}) {
93
193
  if (!next || typeof next !== 'object' || !next.type)
94
194
  return null;
95
- const text = (next.message ?? next.description ?? '').trim();
195
+ const text = mcpText((next.message ?? next.description ?? '').trim());
96
196
  if (next.type === 'done' || next.type === 'none')
97
197
  return `Next: nothing left to do${text ? ` — ${text}` : '.'}`;
198
+ // Round 23 (pilot 22, headless-agent-pr): everything left waits for a deploy or the owner.
199
+ if (next.type === 'done_for_now')
200
+ return `Next: done for this change — ${text || 'the rest waits for a deploy or the owner.'}`;
98
201
  const op = next.operation_id ?? undefined;
99
- const tool = op ? OPERATION_TOOLS[op] : undefined;
202
+ // Prefer the API's own mapping (next_action.mcp_tool); `call_api` means "no dedicated tool".
203
+ const named = next.mcp_tool && next.mcp_tool !== 'call_api' ? next.mcp_tool : undefined;
204
+ const tool = named ?? (op ? OPERATION_TOOLS[op] : undefined);
100
205
  const parts = [`Next (${next.type}): ${text || 'see next_action.'}`];
101
206
  if (tool)
102
- parts.push(`→ MCP tool \`${tool}\`${next.task ? ` (task: ${next.task})` : ''}.`);
207
+ parts.push(`→ MCP tool \`${tool}\`${argHint(tool, next, ctx)}.`);
103
208
  else if (op) {
104
- const endpoint = next.endpoint ?? OPERATION_ENDPOINTS[op];
105
- parts.push(endpoint ? `→ MCP tool \`call_api\` with ${endpoint} (operation ${op}).` : `→ operation ${op} (use \`call_api\`; paths in /v1/openapi.json).`);
209
+ let endpoint = next.endpoint ?? OPERATION_ENDPOINTS[op];
210
+ const site = ctx.data ?? {};
211
+ const siteId = typeof site.site === 'string' && site.site.startsWith('site_') ? site.site : typeof site.id === 'string' && site.id.startsWith('site_') ? site.id : undefined;
212
+ if (endpoint && siteId)
213
+ endpoint = endpoint.replace('{site}', siteId);
214
+ parts.push(endpoint ? `→ MCP tool \`call_api\` with ${endpoint}.` : `→ MCP tool \`call_api\` (operation ${op}; paths in /v1/openapi.json).`);
106
215
  }
107
- if (next.command)
216
+ if (next.command && CLI_PUBLISHED)
108
217
  parts.push(`CLI equivalent: \`${next.command}\`.`);
109
218
  if (next.url && !tool)
110
219
  parts.push(`Docs: ${next.url}`);
@@ -144,6 +253,44 @@ function withItems(data, path, items) {
144
253
  root[path[0]] = { ...root[path[0]], [path[1]]: items };
145
254
  return root;
146
255
  }
256
+ /** A list this short is never paged down to fewer items: its items' nested lists are shortened instead. */
257
+ const SMALL_LIST = 5;
258
+ /**
259
+ * Every item of `arr` kept; in each item, its largest nested list (top level or one level down) is cut to the first `k`
260
+ * entries, with `_truncated` saying how many it had. `k` is the largest that fits. Null when even k = 0 does not fit.
261
+ */
262
+ function fitNestedLists(data, arr, max) {
263
+ const nested = arr.items.map((item) => (item && typeof item === 'object' && !Array.isArray(item) ? largestArray(item) : null));
264
+ const longest = Math.max(0, ...nested.map((n) => n?.items.length ?? 0));
265
+ if (!longest)
266
+ return null;
267
+ const render = (k) => {
268
+ const items = arr.items.map((item, i) => {
269
+ const n = nested[i];
270
+ if (!n || n.items.length <= k)
271
+ return item;
272
+ const cut = withItems(item, n.path, n.items.slice(0, k));
273
+ return { ...cut, _truncated: { list: n.path.join('.'), returned: k, total: n.items.length } };
274
+ });
275
+ return JSON.stringify(withItems(data, arr.path, items), null, 2);
276
+ };
277
+ if (render(0).length > max)
278
+ return null;
279
+ let lo = 0;
280
+ let hi = longest;
281
+ while (lo < hi) {
282
+ const mid = Math.ceil((lo + hi) / 2);
283
+ if (render(mid).length <= max)
284
+ lo = mid;
285
+ else
286
+ hi = mid - 1;
287
+ }
288
+ const lists = [...new Set(nested.filter((n) => !!n && n.items.length > lo).map((n) => n.path.join('.')))];
289
+ return {
290
+ json: render(lo),
291
+ page: `Output shortened: all ${arr.items.length} items of "${arr.path.join('.')}" shown, each with at most ${lo} entries of its "${lists.join('", "')}" list (see _truncated in each item). The JSON is complete and valid; retrieve one item by id to see more of it, or narrow the request.`,
292
+ };
293
+ }
147
294
  /**
148
295
  * Round-3 pilot (nextjs, sveltekit): output was cut at 60 KB in the middle of the JSON, which agents then failed to
149
296
  * parse. Output is always valid JSON: an oversized list is paged (the first items that fit, plus `_page` naming the
@@ -169,6 +316,14 @@ export function fitJson(data, max = MAX_JSON_CHARS) {
169
316
  else
170
317
  hi = mid - 1;
171
318
  }
319
+ // MCP connector test: list_law_updates (limit 3) printed "0 of 2 items shown": each law update carried hundreds of
320
+ // changed requirements, so not even one item fitted. When paging would drop items of a short list (or all of them),
321
+ // every item is shown instead, with its own longest nested list cut to what fits.
322
+ if (lo < arr.items.length && (lo === 0 || arr.items.length <= SMALL_LIST)) {
323
+ const inner = fitNestedLists(data, arr, max);
324
+ if (inner)
325
+ return inner;
326
+ }
172
327
  const json = render(lo);
173
328
  if (json.length <= max) {
174
329
  return { json, page: `Output paged: ${lo} of ${arr.items.length} items of "${arr.path.join('.')}" shown. The JSON is complete and valid; fetch the others by id (_page.remaining_ids), e.g. get_task for plan tasks, or narrow the request (a smaller limit).` };
@@ -182,16 +337,19 @@ export function fitJson(data, max = MAX_JSON_CHARS) {
182
337
  page: `Output too large (${compact.length} characters): narrow the request (a smaller limit, or one item at a time).`,
183
338
  };
184
339
  }
340
+ /** The "Next:" line of a result whose API object names no next step. */
341
+ export const FALLBACK_NEXT = 'Next: nothing more is needed for this result; get_compliance_plan names the site\'s next step.';
342
+ export const FALLBACK_NEXT_ERROR = 'Next: fix what the error names (see the hint above, if any) and call the tool again.';
185
343
  export function renderOutput(out) {
186
- const lines = [out.headline.trim()];
344
+ const lines = [mcpText(out.headline.trim())];
187
345
  for (const n of out.notes ?? [])
188
346
  if (n)
189
- lines.push(`- ${n}`);
190
- const next = describeNextAction(out.next === undefined ? nextActionOf(out.data) : out.next);
191
- if (next)
192
- lines.push(next);
347
+ lines.push(`- ${mcpText(n)}`);
348
+ const next = describeNextAction(out.next === undefined ? nextActionOf(out.data) : out.next, { data: out.data });
349
+ // Round 23 (pilot 22 item 27, agent-discovery: a result with no "Next:" line): every result names what comes next.
350
+ lines.push(next ?? (out.isError ? FALLBACK_NEXT_ERROR : FALLBACK_NEXT));
193
351
  if (out.data !== undefined) {
194
- const fitted = fitJson(out.data);
352
+ const fitted = fitJson(forMcp(out.data));
195
353
  if (fitted.page)
196
354
  lines.push(`- ${fitted.page}`);
197
355
  lines.push('', '```json', fitted.json, '```');
@@ -212,27 +370,53 @@ export function renderError(err) {
212
370
  data.doc_url = err.docUrl;
213
371
  if (err.nextAction)
214
372
  data.next_action = err.nextAction;
215
- const hint = ERROR_HINTS[err.code];
373
+ const hint = err.code === 'resource_missing' ? missingHint(err.message) : ERROR_HINTS[err.code];
216
374
  return renderOutput({
217
375
  headline: `Checkbox API error ${err.code}${err.param ? ` (param: ${err.param})` : ''}: ${err.message}`,
218
376
  notes: hint ? [hint] : [],
219
377
  data: { error: data },
220
- next: err.nextAction ?? null,
378
+ next: err.nextAction ?? (err.code === 'resource_missing' ? missingNext(err.message) : null),
221
379
  isError: true,
222
380
  });
223
381
  }
224
382
  const message = err instanceof Error ? err.message : String(err);
225
383
  return renderOutput({ headline: `Tool failed: ${message}`, isError: true });
226
384
  }
385
+ /** Audit P2: where to find a valid id of the kind that was missing (by its prefix in the message). */
386
+ function missingKind(message) {
387
+ if (/\btask_/.test(message))
388
+ return 'task';
389
+ if (/\bver(?:ification)?_/.test(message))
390
+ return 'verification';
391
+ if (/\bfinding_/.test(message))
392
+ return 'finding';
393
+ if (/\bsite_/.test(message))
394
+ return 'site';
395
+ return 'other';
396
+ }
397
+ function missingHint(message) {
398
+ const where = { task: 'get_compliance_plan lists the current task ids', verification: 'get_task shows a task\'s last_verification id', finding: 'list_findings lists finding ids', site: 'list_sites lists site ids', other: 'get_compliance_plan and list_sites list the current ids' }[missingKind(message)];
399
+ return `The id does not exist in this mode (test keys only see test-mode objects): ${where}.`;
400
+ }
401
+ function missingNext(message) {
402
+ const k = missingKind(message);
403
+ if (k === 'site')
404
+ return { type: 'find_id', message: 'Call list_sites for the site ids in this mode.', operation_id: 'listSites' };
405
+ if (k === 'finding')
406
+ return { type: 'find_id', message: 'Call list_findings for the finding ids.', operation_id: 'listFindings' };
407
+ return { type: 'find_id', message: 'Call get_compliance_plan for the current task ids (each task carries its last_verification id).', operation_id: 'retrieveCompliancePlan' };
408
+ }
227
409
  const ERROR_HINTS = {
228
- insufficient_scope: 'The API key this MCP server uses is a restricted key without that permission. Ask the human for a key with the permission (Dashboard → Developers → API keys) or use a read-only tool.',
410
+ insufficient_scope: 'This connection lacks that permission (a read-only connection or a restricted key). Ask the human to reconnect Checkbox and choose Full access on the approval page: Codex: `codex mcp logout checkbox`, then `codex mcp login checkbox`; Claude Code: `/mcp` → checkbox → Clear authentication → Authenticate; claude.ai / Claude Desktop: Settings → Connectors → Checkbox → Disconnect, then Connect. For an API key, change its permissions in the Checkbox dashboard → Agent & API. Never ask them to paste a key. Read-only tools still work.',
229
411
  insufficient_role: 'The key acts as a viewer; writes need a member or admin key.',
230
412
  api_key_missing: 'No Checkbox API key is configured: call setup_checkbox (one browser approval by the human) to get one.',
231
- api_key_invalid: 'The configured Checkbox API key is unknown, revoked or expired: call setup_checkbox to get a new one, or ask the human for a valid key.',
413
+ api_key_invalid: 'The configured Checkbox API key is unknown, revoked or expired: reconnect with setup_checkbox (one browser approval by the human), or ask the human to fix the key in the Checkbox dashboard → Developers. Never ask them to paste a key into the chat.',
232
414
  account_not_activated: 'Live mode is locked until a site domain is verified. Use a test key (ck_test_…) for setup and checks, or verify the domain first.',
233
415
  parameter_invalid: 'Fix the named parameter and call the tool again.',
234
416
  parameter_missing: 'Add the named parameter and call the tool again.',
235
417
  parameter_unknown: 'Remove the named parameter and call the tool again.',
236
- resource_missing: 'The id does not exist in this mode (test keys only see test-mode objects). Use list_sites / list_findings to find valid ids.',
237
418
  rate_limit_exceeded: 'Wait the number of seconds in the message, then retry.',
419
+ shopify_not_connected: 'Only the merchant can connect the store: tell them the step in next_action (install the Checkbox app in the Shopify admin, then "Connect to Checkbox"). Do not retry until they say it is done.',
420
+ terms_incomplete: 'Checkbox never guesses prices or cancellation terms: send the missing plan facts (next_action), then call the tool again.',
421
+ platform_error: 'The merchant\'s platform refused the change; nothing was applied. Retry in a few minutes.',
238
422
  };
package/dist/index.d.ts CHANGED
@@ -4,4 +4,5 @@ export { NO_KEY, keyAllows, keyContextFromPrefix, type KeyContext, type KeyRole,
4
4
  export { CATALOG_URI, QUICKSTART_URI, makeMyCheckoutCompliantPrompt, quickstartMarkdown, renderCatalog, type CoverageDoc } from './resources.ts';
5
5
  export { DEFAULT_MCP_URL, SERVER_INSTRUCTIONS, SERVER_NAME, SERVER_VERSION, createCheckboxMcpServer, type CheckboxMcp, type CheckboxMcpOptions } from './server.ts';
6
6
  export { TOOLS, TOOL_OPERATIONS, ToolError, toolVisible, type IssuedKeys, type ToolContext, type ToolDef } from './tools.ts';
7
+ export { closestArgument, unknownArgumentsMessage } from './unknown-args.ts';
7
8
  export { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
package/dist/index.js CHANGED
@@ -6,5 +6,6 @@ export { NO_KEY, keyAllows, keyContextFromPrefix } from "./permissions.js";
6
6
  export { CATALOG_URI, QUICKSTART_URI, makeMyCheckoutCompliantPrompt, quickstartMarkdown, renderCatalog } from "./resources.js";
7
7
  export { DEFAULT_MCP_URL, SERVER_INSTRUCTIONS, SERVER_NAME, SERVER_VERSION, createCheckboxMcpServer } from "./server.js";
8
8
  export { TOOLS, TOOL_OPERATIONS, ToolError, toolVisible } from "./tools.js";
9
+ export { closestArgument, unknownArgumentsMessage } from "./unknown-args.js";
9
10
  // Re-exported so hosts use the same SDK copy as the server.
10
11
  export { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
@@ -0,0 +1,21 @@
1
+ export interface KeysToSave {
2
+ secret_key: string;
3
+ publishable_key?: string | null;
4
+ live_secret_key?: string | null;
5
+ live_publishable_key?: string | null;
6
+ }
7
+ export type SavedKeys = {
8
+ ok: true;
9
+ file: string;
10
+ gitignored: boolean;
11
+ note?: string;
12
+ } | {
13
+ ok: false;
14
+ error: string;
15
+ };
16
+ export declare const ENV_FILE = ".env.local";
17
+ export declare const FALLBACK_FILE = ".checkbox/keys.env";
18
+ /** Writes the issued keys to the project's git-ignored env file. Never throws. */
19
+ export declare function saveKeysToProject(root: string, keys: KeysToSave): SavedKeys;
20
+ /** The CHECKBOX_SECRET_KEY saved by an earlier device login in this folder (.env.local, then .checkbox/keys.env). */
21
+ export declare function readSavedKey(root: string): string | undefined;
@@ -0,0 +1,118 @@
1
+ // Round 25 (pilot 24 item 12, security-reviewer): the stdio device login returned the full ck_test_ secret key in the
2
+ // tool result, so it landed in the LLM transcript (and in whatever logs the client keeps). The local stdio server now
3
+ // writes the issued keys to a git-ignored file in the project itself and returns only a hint ("ck_test_…ab12" and the
4
+ // file name). The next start of `checkbox-mcp` in the same folder reads the key back from that file.
5
+ //
6
+ // Where: .env.local in the project folder (what `checkbox init` writes, and what most frameworks load), unless git
7
+ // tracks .env.local (a secret written there would go into the next commit even with .gitignore): then
8
+ // .checkbox/keys.env, in a folder whose own .gitignore ignores everything.
9
+ import { execFileSync } from 'node:child_process';
10
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ export const ENV_FILE = '.env.local';
13
+ export const FALLBACK_FILE = '.checkbox/keys.env';
14
+ function read(path) {
15
+ try {
16
+ return readFileSync(path, 'utf8');
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
22
+ function gitTracked(root, file) {
23
+ try {
24
+ execFileSync('git', ['ls-files', '--error-unmatch', '--', file], { cwd: root, stdio: 'ignore', timeout: 5000 });
25
+ return true;
26
+ }
27
+ catch {
28
+ return false;
29
+ }
30
+ }
31
+ function gitIgnores(root, file) {
32
+ try {
33
+ execFileSync('git', ['check-ignore', '-q', '--', file], { cwd: root, stdio: 'ignore', timeout: 5000 });
34
+ return true;
35
+ }
36
+ catch (err) {
37
+ // Exit 1: not ignored. Anything else (no git, not a repository): unknown.
38
+ return err.status === 1 ? false : null;
39
+ }
40
+ }
41
+ function upsertLines(existing, entries, header) {
42
+ let text = existing ?? '';
43
+ const missing = [];
44
+ for (const [k, v] of entries) {
45
+ const re = new RegExp(`^${k}=.*$`, 'm');
46
+ if (re.test(text))
47
+ text = text.replace(re, `${k}=${v}`);
48
+ else
49
+ missing.push([k, v]);
50
+ }
51
+ if (missing.length) {
52
+ const block = [header, ...missing.map(([k, v]) => `${k}=${v}`)];
53
+ text = `${text}${text && !text.endsWith('\n') ? '\n' : ''}${text ? '\n' : ''}${block.join('\n')}\n`;
54
+ }
55
+ return text;
56
+ }
57
+ /** Makes sure .gitignore covers .env.local (only in a git project, or when a .gitignore already exists). */
58
+ function ensureIgnored(root, file) {
59
+ const ignored = gitIgnores(root, file);
60
+ if (ignored === true)
61
+ return true;
62
+ const path = join(root, '.gitignore');
63
+ const existing = read(path);
64
+ if (existing !== null) {
65
+ const lines = existing.split(/\r?\n/).map((l) => l.trim());
66
+ if (lines.some((l) => l === file || l === `/${file}` || l === '.env*' || l === '.env*.local' || l === '*.local' || l === '.env.local*'))
67
+ return true;
68
+ }
69
+ if (existing === null && !existsSync(join(root, '.git')))
70
+ return false;
71
+ writeFileSync(path, `${existing ?? ''}${existing && !existing.endsWith('\n') ? '\n' : ''}# Checkbox keys (checkbox-mcp)\n${file}\n`);
72
+ return true;
73
+ }
74
+ /** Writes the issued keys to the project's git-ignored env file. Never throws. */
75
+ export function saveKeysToProject(root, keys) {
76
+ const entries = [['CHECKBOX_SECRET_KEY', keys.secret_key]];
77
+ if (keys.publishable_key)
78
+ entries.push(['CHECKBOX_PUBLISHABLE_KEY', keys.publishable_key]);
79
+ if (keys.live_secret_key)
80
+ entries.push(['CHECKBOX_LIVE_SECRET_KEY', keys.live_secret_key]);
81
+ if (keys.live_publishable_key)
82
+ entries.push(['CHECKBOX_LIVE_PUBLISHABLE_KEY', keys.live_publishable_key]);
83
+ const header = '# Checkbox (subscription compliance), written by checkbox-mcp. Secret: never commit this file.';
84
+ try {
85
+ if (!gitTracked(root, ENV_FILE)) {
86
+ const path = join(root, ENV_FILE);
87
+ writeFileSync(path, upsertLines(read(path), entries, header), { mode: 0o600 });
88
+ try {
89
+ chmodSync(path, 0o600);
90
+ }
91
+ catch {
92
+ // best effort (some file systems ignore modes)
93
+ }
94
+ const gitignored = ensureIgnored(root, ENV_FILE);
95
+ return { ok: true, file: ENV_FILE, gitignored };
96
+ }
97
+ const dir = join(root, '.checkbox');
98
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
99
+ if (read(join(dir, '.gitignore')) === null)
100
+ writeFileSync(join(dir, '.gitignore'), '# Checkbox local secrets: never committed\n*\n');
101
+ const path = join(root, FALLBACK_FILE);
102
+ writeFileSync(path, upsertLines(read(path), entries, header), { mode: 0o600 });
103
+ return { ok: true, file: FALLBACK_FILE, gitignored: true, note: `${ENV_FILE} is tracked by git, so the key was not written there.` };
104
+ }
105
+ catch (err) {
106
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
107
+ }
108
+ }
109
+ /** The CHECKBOX_SECRET_KEY saved by an earlier device login in this folder (.env.local, then .checkbox/keys.env). */
110
+ export function readSavedKey(root) {
111
+ for (const file of [ENV_FILE, FALLBACK_FILE]) {
112
+ const text = read(join(root, file));
113
+ const m = text ? /^CHECKBOX_SECRET_KEY=["']?([A-Za-z0-9_]+)["']?\s*$/m.exec(text) : null;
114
+ if (m?.[1])
115
+ return m[1];
116
+ }
117
+ return undefined;
118
+ }