@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 +3 -3
- package/dist/api.d.ts +7 -0
- package/dist/api.js +11 -1
- package/dist/format.d.ts +26 -1
- package/dist/format.js +202 -18
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/key-file.d.ts +21 -0
- package/dist/key-file.js +118 -0
- package/dist/resources.js +16 -13
- package/dist/server.d.ts +29 -3
- package/dist/server.js +98 -10
- package/dist/stdio.d.ts +2 -0
- package/dist/stdio.js +11 -1
- package/dist/todo.d.ts +25 -0
- package/dist/todo.js +35 -0
- package/dist/tools.d.ts +163 -3
- package/dist/tools.js +922 -199
- package/dist/unknown-args.d.ts +22 -0
- package/dist/unknown-args.js +119 -0
- package/dist/version-check.d.ts +22 -0
- package/dist/version-check.js +50 -0
- package/package.json +10 -2
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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}\`${
|
|
207
|
+
parts.push(`→ MCP tool \`${tool}\`${argHint(tool, next, ctx)}.`);
|
|
103
208
|
else if (op) {
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
192
|
-
|
|
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: '
|
|
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:
|
|
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;
|
package/dist/key-file.js
ADDED
|
@@ -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
|
+
}
|