meguro-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,11 @@
1
+ # Changelog
2
+
3
+ All notable changes to `meguro-mcp` are recorded here. Versions follow Semantic Versioning.
4
+
5
+ ## 0.1.0 — 2026-07-19
6
+
7
+ - First public distribution of the dependency-free Meguro control-plane MCP server.
8
+ - Exposes the 15 documented store, run, receipt, connection, probe, and schema tools.
9
+ - Supports account-scoped `meg_sk_…` credentials and the self-hosted operator fallback.
10
+ - Redacts control credentials and practice-store credentials from protocol and tool errors.
11
+
package/README.md ADDED
@@ -0,0 +1,141 @@
1
+ # meguro-mcp — the control plane for your AI tools
2
+
3
+ Drive Meguro worlds from Claude Code, Cursor, or any MCP client: start a run that writes months of
4
+ believable history onto a real dev store, or orchestrate an external agent against a tenant-owned
5
+ practice store and collect bounded receipt evidence.
6
+
7
+ This is the **control plane** (world/run management, for the developer's tools). It is not the
8
+ **data plane** (the practice-lane Shopify facade the agent under test talks to).
9
+
10
+ Dependency-free, no build step: the server is plain Node ≥ 20.
11
+
12
+ ## Install and run
13
+
14
+ Customer MCP clients run the exact public version directly from npm:
15
+
16
+ ```bash
17
+ npx -y meguro-mcp@0.1.0
18
+ ```
19
+
20
+ Pin the version in client configuration. A pinned quickstart stays reproducible and never changes its
21
+ tool contract because npm published a newer release.
22
+
23
+ ## Transport and entry point
24
+
25
+ The public control-plane MCP server is the local stdio command shown below. It speaks newline-delimited JSON-RPC over stdin/stdout and uses `MEGURO_API_BASE_URL` plus `MEGURO_API_TOKEN` for its authenticated HTTP calls. There is no control-plane `POST ${MEGURO_API_BASE_URL}/mcp` endpoint.
26
+
27
+ Practice stores separately expose a Storefront shopper/data-plane MCP endpoint at `https://<worldId>.meguro.io/api/mcp`. That endpoint is not this server and cannot start runs, reveal Admin connection details, inspect schemas, or collect receipts.
28
+
29
+ Console provides fast trusted proof and evidence inspection; the agency agent normally remains in its own environment. A practice-store endpoint/token remains stable while commerce time moves through explicit run tools. Practice evidence is deterministic scenario rehearsal, not merchant forecasting. Console-proof actions are not credited to the agent, and Release Gate evidence is not public certification.
30
+
31
+ ## Tools
32
+
33
+ | Tool | Does | Auth |
34
+ |---|---|---|
35
+ | `run_start` | write `months` of dated history onto the configured store (requires typing the shop domain in `confirm_shop`) | account API key |
36
+ | `run_status` | live progress: orders/total, watermark, status, lastError | account API key |
37
+ | `run_ledger` | the run's attributed diary | account API key |
38
+ | `run_report` | verdict + summary + S3 keys | account API key |
39
+ | `run_resume` | resume a failed run after raising budgets | account API key |
40
+ | `runs_diff` | twin comparison: shared prefix, fork, deltas | account API key |
41
+ | `practice_run_start` | start an `external-agent` practice run; the agent remains in your environment | account API key |
42
+ | `practice_run_status` | read the server-authoritative practice-run contract | account API key |
43
+ | `practice_run_checkpoint` | capture immutable temporal evidence and return a bounded summary | account API key |
44
+ | `practice_run_advance` | advance by days or a closed-set event condition with both concurrency cursors | account API key |
45
+ | `practice_run_finish` | explicitly finish without inventing time or evidence | account API key |
46
+ | `practice_run_report` | read the run's **receipt** — a bounded receipt summary without raw private payloads | account API key |
47
+ | `get_connection_details` | fetch the three Shopify Admin values for a practice store | account API key |
48
+ | `admin_probe` | run one isolated, ungraded Admin GraphQL read or confirmed mutation and return a durable Console URL | account API key |
49
+ | `admin_schema` | look up one exact bounded Admin type, field, mutation, or commerce recipe | account API key |
50
+
51
+ ## Identity model
52
+
53
+ The whole assistant workflow uses exactly two identifiers:
54
+
55
+ * **`storeId`** — the canonical public practice-store id. `get_connection_details` takes it and returns it, and `practice_run_start` consumes it verbatim, so connection-discovery output passes directly into run start. `worldId` is a **legacy alias for the same id** (both tools still accept it and connection results still include it); prefer `storeId` in new integrations.
56
+ * **`attemptId`** — the run identity returned by `practice_run_start`. Every later `practice_run_*` tool takes `attemptId`, never the store id.
57
+
58
+ Receipt retrieval is `practice_run_report`: **receipt** is the user-facing noun; `report` (the tool suffix, the `/report` route, and the `operation: "report"` result field) is the protocol/route **implementation compatibility name** for the same artifact.
59
+
60
+ ## External-agent practice sequence
61
+
62
+ Use these tool names in order:
63
+
64
+ 1. `get_connection_details({ storeId })` through the explicit secure flow (`worldId` remains a legacy alias). Keep the returned values out of model/chat logs.
65
+ 2. `practice_run_start({ storeId, clock: { mode: "harness", simulationDays: 14, stepDays: 1 } })` — the same `storeId` the previous step took and returned.
66
+ 3. Launch the agency agent in its own environment with the stable returned `SHOPIFY_*` values.
67
+ 4. `practice_run_status({ attemptId })`, then `practice_run_checkpoint({ attemptId })`.
68
+ 5. `practice_run_advance({ attemptId, days: 1, expectedDay, expectedCallSeq })`, using the exact current state cursors. Supply `until` instead of `days` for a supported event stop.
69
+ 6. Inspect the aligned checkpoint evidence in Console or the run APIs.
70
+ 7. `practice_run_finish({ attemptId })`, then `practice_run_report({ attemptId })` for the run's receipt.
71
+ 8. Optionally execute an immutable temporal probe-set revision through the HTTP surface and inspect/export it in Gate.
72
+
73
+ `get_connection_details` deliberately returns `SHOPIFY_ADMIN_ACCESS_TOKEN`: it is the store-scoped
74
+ data-plane credential your commerce agent needs for the exact returned `*.meguro.io` Admin URL. Treat
75
+ it as a secret, provide it only to that agent process, and keep it out of model/chat logs. It is not the
76
+ account-scoped `MEGURO_API_TOKEN` and cannot control other stores or account resources.
77
+
78
+ `manual`, `harness`, and `scheduled` are clock ownership modes. Raw Admin GraphQL requests do not each
79
+ advance time. Practice-run status, checkpoint, and receipt responses deliberately exclude raw private
80
+ evidence and credentials; `get_connection_details` is the one explicit credential-returning tool.
81
+
82
+ Generate the exact placeholder-only MCP sequence from the repo:
83
+
84
+ ```bash
85
+ node byo-ci-test/phase7-snippets.mjs mcp
86
+ ```
87
+
88
+ See [the agency quickstart](../../docs/agency-practice-run-quickstart.md) and [HTTP reference](../../docs/practice-run-http-api.md).
89
+
90
+ ## Environment
91
+
92
+ ```
93
+ MEGURO_API_BASE_URL=https://api-dev.meguro.io
94
+ MEGURO_API_TOKEN=<meg_sk account key from Console → Settings>
95
+ MEGURO_DASHBOARD_URL=https://<dashboard-distribution>.cloudfront.net # optional, enables dashboard links
96
+
97
+ # Self-hosted/operator fallback only; customer MCP clients should omit it.
98
+ MEGURO_CONTROL_API_KEY=<from infra/params.env or deployment secrets>
99
+ ```
100
+
101
+ ## Register — Claude Code
102
+
103
+ ```bash
104
+ claude mcp add meguro \
105
+ -e MEGURO_API_BASE_URL=https://api-dev.meguro.io \
106
+ -e MEGURO_API_TOKEN=meg_sk_... \
107
+ -e MEGURO_DASHBOARD_URL=https://... \
108
+ -- npx -y meguro-mcp@0.1.0
109
+ ```
110
+
111
+ ## Register — Cursor (`.cursor/mcp.json`)
112
+
113
+ ```json
114
+ {
115
+ "mcpServers": {
116
+ "meguro": {
117
+ "command": "npx",
118
+ "args": ["-y", "meguro-mcp@0.1.0"],
119
+ "env": {
120
+ "MEGURO_API_BASE_URL": "https://api-dev.meguro.io",
121
+ "MEGURO_API_TOKEN": "meg_sk_...",
122
+ "MEGURO_DASHBOARD_URL": "https://..."
123
+ }
124
+ }
125
+ }
126
+ }
127
+ ```
128
+
129
+ Then, in your assistant: *"Start a Meguro world with 12 months of history on `<SHOP_DOMAIN>`,
130
+ seed `bfcm-regression`, and give me the dashboard link."*
131
+
132
+ ## Tests
133
+
134
+ ```bash
135
+ npm test --workspace meguro-mcp
136
+ ```
137
+
138
+ The test suite packs the public artifact, installs it into a temporary clean-room project, starts the
139
+ installed binary, and requires `tools/list` to return exactly 15 tools without leaking supplied
140
+ credentials to stdout or stderr. Maintainers should follow [RELEASING.md](./RELEASING.md) for every
141
+ version; release notes are in [CHANGELOG.md](./CHANGELOG.md).
package/RELEASING.md ADDED
@@ -0,0 +1,49 @@
1
+ # Releasing `meguro-mcp`
2
+
3
+ The public package name is `meguro-mcp`. Customer configurations invoke an exact version with
4
+ `npx -y meguro-mcp@<version>`; do not deploy a Console quickstart for a version that is not already
5
+ available from the public npm registry.
6
+
7
+ ## Release checklist
8
+
9
+ 1. Choose the next Semantic Versioning number.
10
+ 2. Update `version` in `package.json`, `SERVER_INFO.version` in `src/server.mjs`, the pinned
11
+ `MEGURO_MCP_PACKAGE` value in `packages/dashboard/src/console.js`, and `CHANGELOG.md` in one commit.
12
+ 3. From the repository root, run:
13
+
14
+ ```bash
15
+ npm test --workspace meguro-mcp
16
+ npm run verify:publish --workspace meguro-mcp
17
+ npm pack --workspace meguro-mcp --dry-run
18
+ ```
19
+
20
+ 4. Confirm npm authentication and the intended identity:
21
+
22
+ ```bash
23
+ npm whoami
24
+ ```
25
+
26
+ 5. From `packages/meguro-mcp`, publish the exact version. npm will run the complete test suite and
27
+ package validator through the package lifecycle before it uploads anything:
28
+
29
+ ```bash
30
+ npm publish --access public
31
+ ```
32
+
33
+ 6. Verify the public artifact from a directory outside this repository:
34
+
35
+ ```bash
36
+ npm view meguro-mcp@<version> version dist.integrity
37
+ npx -y meguro-mcp@<version>
38
+ ```
39
+
40
+ For the protocol smoke, use the copy-ready Console configuration with a disposable workspace key
41
+ and confirm `tools/list` returns exactly 15 tools. Never put the key in shell history, logs, tickets,
42
+ or chat.
43
+
44
+ 7. Only after the registry and clean-room checks pass, deploy the Console quickstart, tag the commit as
45
+ `meguro-mcp-v<version>`, and publish MCP directory listings.
46
+
47
+ If any step fails, do not bump the Console pin, deploy the quickstart, tag the release, or publish a
48
+ directory listing.
49
+
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "meguro-mcp",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "Meguro control-plane MCP server: drive worlds, runs, and verdicts from your own AI tools.",
7
+ "homepage": "https://meguro.io",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/darko2006/meguro.git",
11
+ "directory": "packages/meguro-mcp"
12
+ },
13
+ "keywords": [
14
+ "mcp",
15
+ "model-context-protocol",
16
+ "shopify",
17
+ "testing",
18
+ "commerce-agents"
19
+ ],
20
+ "engines": {
21
+ "node": ">=20"
22
+ },
23
+ "files": [
24
+ "src/",
25
+ "scripts/verify-package.mjs",
26
+ "README.md",
27
+ "CHANGELOG.md",
28
+ "RELEASING.md"
29
+ ],
30
+ "bin": {
31
+ "meguro-mcp": "src/server.mjs"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "scripts": {
37
+ "pretest": "npm run build -w @meguro/core",
38
+ "test": "node --test test/*.test.mjs",
39
+ "verify:publish": "node scripts/verify-package.mjs",
40
+ "prepack": "npm run verify:publish",
41
+ "prepublishOnly": "npm test"
42
+ }
43
+ }
@@ -0,0 +1,27 @@
1
+ import assert from 'node:assert/strict';
2
+ import { readFileSync, statSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const root = join(dirname(fileURLToPath(import.meta.url)), '..');
7
+ const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
8
+ const server = readFileSync(join(root, 'src/server.mjs'), 'utf8');
9
+ const readme = readFileSync(join(root, 'README.md'), 'utf8');
10
+ const changelog = readFileSync(join(root, 'CHANGELOG.md'), 'utf8');
11
+ const releasing = readFileSync(join(root, 'RELEASING.md'), 'utf8');
12
+
13
+ assert.equal(pkg.name, 'meguro-mcp');
14
+ assert.equal(pkg.private, false);
15
+ assert.equal(pkg.bin?.['meguro-mcp'], 'src/server.mjs');
16
+ assert.equal(pkg.engines?.node, '>=20');
17
+ assert.equal(pkg.publishConfig?.access, 'public');
18
+ assert.match(server, /^#!\/usr\/bin\/env node/);
19
+ assert.ok((statSync(join(root, 'src/server.mjs')).mode & 0o111) !== 0, 'server entry point must be executable');
20
+ assert.ok(server.includes(`const SERVER_INFO = { name: 'meguro', version: '${pkg.version}' };`), 'server version must match package version');
21
+ assert.match(readme, new RegExp(`npx -y meguro-mcp@${pkg.version.replaceAll('.', '\\.')}`));
22
+ assert.match(readme, /SHOPIFY_ADMIN_ACCESS_TOKEN/);
23
+ assert.match(readme, /store-scoped\s+data-plane credential/);
24
+ assert.match(changelog, new RegExp(`## ${pkg.version.replaceAll('.', '\\.')}`));
25
+ assert.match(releasing, /npm publish --access public/);
26
+
27
+ console.log(`meguro-mcp@${pkg.version} publish contract verified`);
package/src/server.mjs ADDED
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env node
2
+ // Meguro control-plane MCP server — stdio transport, newline-delimited JSON-RPC 2.0.
3
+ // Dependency-free on purpose: the protocol subset we need (initialize, tools/list,
4
+ // tools/call, ping) is small and stable; no lockfile churn, no build step.
5
+
6
+ import { createInterface } from 'node:readline';
7
+ import { createTools, redactSecrets } from './tools.mjs';
8
+
9
+ const SERVER_INFO = { name: 'meguro', version: '0.1.0' };
10
+ const PROTOCOL_VERSION = '2025-03-26';
11
+
12
+ const tools = createTools({
13
+ apiBaseUrl: process.env.MEGURO_API_BASE_URL,
14
+ controlApiKey: process.env.MEGURO_CONTROL_API_KEY,
15
+ practiceApiToken: process.env.MEGURO_API_TOKEN,
16
+ dashboardUrl: process.env.MEGURO_DASHBOARD_URL,
17
+ });
18
+
19
+ function send(message) {
20
+ process.stdout.write(`${JSON.stringify(message)}\n`);
21
+ }
22
+
23
+ function reply(id, result) {
24
+ send({ jsonrpc: '2.0', id, result });
25
+ }
26
+
27
+ function replyError(id, code, message) {
28
+ send({ jsonrpc: '2.0', id, error: { code, message: redactSecrets(message) } });
29
+ }
30
+
31
+ async function handle(message) {
32
+ const { id, method, params } = message;
33
+ const isNotification = id === undefined || id === null;
34
+
35
+ switch (method) {
36
+ case 'initialize':
37
+ return reply(id, {
38
+ protocolVersion: typeof params?.protocolVersion === 'string' ? params.protocolVersion : PROTOCOL_VERSION,
39
+ capabilities: { tools: {} },
40
+ serverInfo: SERVER_INFO,
41
+ });
42
+ case 'notifications/initialized':
43
+ case 'notifications/cancelled':
44
+ return; // notifications need no reply
45
+ case 'ping':
46
+ return reply(id, {});
47
+ case 'tools/list':
48
+ return reply(id, { tools: tools.definitions });
49
+ case 'tools/call': {
50
+ const result = await tools.call(params?.name, params?.arguments ?? {});
51
+ return reply(id, result);
52
+ }
53
+ default:
54
+ if (!isNotification) replyError(id, -32601, `Method not found: ${method}`);
55
+ }
56
+ }
57
+
58
+ let pending = 0;
59
+ let stdinClosed = false;
60
+
61
+ function maybeExit() {
62
+ // Exit only after stdout has flushed every queued reply (write callbacks run in order).
63
+ if (stdinClosed && pending === 0) process.stdout.write('', () => process.exit(0));
64
+ }
65
+
66
+ const rl = createInterface({ input: process.stdin, terminal: false });
67
+ rl.on('line', (line) => {
68
+ const text = line.trim();
69
+ if (!text) return;
70
+ let message;
71
+ try {
72
+ message = JSON.parse(text);
73
+ } catch {
74
+ return replyError(null, -32700, 'Parse error');
75
+ }
76
+ pending += 1;
77
+ handle(message)
78
+ .catch((error) => {
79
+ if (message.id !== undefined && message.id !== null) {
80
+ replyError(message.id, -32603, error instanceof Error ? error.message : String(error));
81
+ }
82
+ })
83
+ .finally(() => {
84
+ pending -= 1;
85
+ maybeExit();
86
+ });
87
+ });
88
+ // Drain in-flight requests before exiting — a closed stdin ends the session, not mid-air replies.
89
+ rl.on('close', () => { stdinClosed = true; maybeExit(); });
package/src/tools.mjs ADDED
@@ -0,0 +1,671 @@
1
+ // Meguro control-plane MCP tools — a thin, dependency-free wrapper over the deployed control API.
2
+ // Vocabulary discipline (docs/fable/teaching.md): store, run, receipt, gate.
3
+ // Customer control-plane calls use an account-scoped MEGURO_API_TOKEN (meg_sk_...).
4
+ // MEGURO_CONTROL_API_KEY remains a self-hosted/operator fallback; there is no public run share surface.
5
+
6
+ const AGED_BUDGET = (months) => Math.max(100, months * 31 * 8 + 50);
7
+ const SECRET_VALUE = /\bmeg_(?:pw|sk)_[a-z0-9_-]+\b|\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=:-]+/giu;
8
+ const SECRET_HEADER = /\b(?:authorization|cookie|set-cookie|x-shopify-access-token|signed-snapshot-token)\s*[:=]\s*[^\s,;]+/giu;
9
+ const SECRET_KEY = /(?:authorization|cookie|password|secret|token|api[_-]?key|signedsnapshot)/iu;
10
+ const PRIVATE_RESPONSE_KEY = /^(?:raw|rawBody|rawBodyPreview|responsePayload|requestHeaders|responseHeaders|headers|cookies)$/iu;
11
+ const RESERVED_WORLD_IDS = new Set(['api', 'api-dev', 'www', 'dev', 'hooks', 'control']);
12
+
13
+ function textResult(value) {
14
+ return { content: [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }] };
15
+ }
16
+
17
+ function errorResult(message) {
18
+ return { content: [{ type: 'text', text: scrubString(message) }], isError: true };
19
+ }
20
+
21
+ function scrubString(value) {
22
+ return String(value ?? '').replace(SECRET_VALUE, '[REDACTED]').replace(SECRET_HEADER, '[REDACTED]');
23
+ }
24
+
25
+ export function redactSecrets(value) {
26
+ return scrubString(value);
27
+ }
28
+
29
+ function secretSafe(value, depth = 0) {
30
+ if (depth > 12) return '[TRUNCATED]';
31
+ if (typeof value === 'string') return scrubString(value).slice(0, 10_000);
32
+ if (Array.isArray(value)) return value.slice(0, 100).map((child) => secretSafe(child, depth + 1));
33
+ if (value && typeof value === 'object') {
34
+ return Object.fromEntries(Object.entries(value)
35
+ .filter(([key]) => !SECRET_KEY.test(key) && !PRIVATE_RESPONSE_KEY.test(key))
36
+ .slice(0, 100)
37
+ .map(([key, child]) => [key, secretSafe(child, depth + 1)]));
38
+ }
39
+ return value;
40
+ }
41
+
42
+ function practiceErrorResult(status, value, retryAfterSeconds) {
43
+ const body = value && typeof value === 'object' ? value : {};
44
+ const stable = { httpStatus: status };
45
+ if (body.practiceRunError && typeof body.practiceRunError === 'object') {
46
+ stable.practiceRunError = secretSafe({
47
+ code: body.practiceRunError.code,
48
+ message: body.practiceRunError.message,
49
+ });
50
+ } else if (body.error && typeof body.error === 'object') {
51
+ stable.error = secretSafe({ code: body.error.code, message: body.error.message });
52
+ } else if (Array.isArray(body.errors) && body.errors.length) {
53
+ stable.errors = body.errors.slice(0, 5).map((error) => secretSafe({ code: error?.code, message: error?.message }));
54
+ } else {
55
+ stable.error = { message: scrubString(typeof value === 'string' ? value : 'Meguro practice request failed.') };
56
+ }
57
+ const bodyRetryAfter = Number(body.retryAfterSeconds ?? body.retry?.afterSeconds);
58
+ if (Number.isFinite(retryAfterSeconds) && retryAfterSeconds >= 0) stable.retryAfterSeconds = retryAfterSeconds;
59
+ else if (Number.isFinite(bodyRetryAfter) && bodyRetryAfter >= 0) stable.retryAfterSeconds = bodyRetryAfter;
60
+ if (typeof body.retryable === 'boolean') stable.retryable = body.retryable;
61
+ return { content: [{ type: 'text', text: JSON.stringify(stable, null, 2) }], isError: true };
62
+ }
63
+
64
+ function requiredString(args, key) {
65
+ const value = args?.[key];
66
+ if (typeof value !== 'string' || value.trim().length === 0) throw new Error(`${key} is required`);
67
+ return value.trim();
68
+ }
69
+
70
+ function validatedStoreId(value, key) {
71
+ if (!/^[a-z0-9-]+$/.test(value)) throw new Error(`${key} must match ^[a-z0-9-]+$`);
72
+ if (RESERVED_WORLD_IDS.has(value)) throw new Error(`${key} uses a reserved Meguro hostname`);
73
+ return value;
74
+ }
75
+
76
+ function requiredWorldId(args) {
77
+ return validatedStoreId(requiredString(args, 'worldId'), 'worldId');
78
+ }
79
+
80
+ // MEG-256: `storeId` is the one canonical public practice-store identifier across the assistant
81
+ // workflow (connection discovery → run start → receipt). `worldId` names the same identifier and
82
+ // stays accepted as a bounded legacy alias so existing callers keep working.
83
+ function requiredPracticeStoreId(args) {
84
+ const provided = ['storeId', 'worldId'].filter((key) => args?.[key] !== undefined && args?.[key] !== null);
85
+ if (provided.length === 0) throw new Error('storeId is required (worldId is accepted as a legacy alias for the same practice-store id)');
86
+ const canonical = requiredString(args, provided[0]);
87
+ if (provided.length === 2 && requiredString(args, provided[1]) !== canonical) {
88
+ throw new Error('storeId and worldId name the same practice store; they were both provided with different values');
89
+ }
90
+ return validatedStoreId(canonical, provided[0]);
91
+ }
92
+
93
+ function optionalPositiveInteger(args, key) {
94
+ const value = args?.[key];
95
+ if (value === undefined) return undefined;
96
+ if (!Number.isSafeInteger(value) || value < 1) throw new Error(`${key} must be a positive integer`);
97
+ return value;
98
+ }
99
+
100
+ function requiredNonNegativeInteger(args, key, minimum = 0) {
101
+ const value = args?.[key];
102
+ if (!Number.isSafeInteger(value) || value < minimum) throw new Error(`${key} must be a whole number of ${minimum} or greater`);
103
+ return value;
104
+ }
105
+
106
+ function practiceRunProjection(value) {
107
+ return value && typeof value === 'object' ? secretSafe(value) : null;
108
+ }
109
+
110
+ function checkpointProjection(value) {
111
+ if (!value || typeof value !== 'object') return null;
112
+ const observations = Array.isArray(value.observations) ? value.observations.slice(0, 3).map((observation) => ({
113
+ captureId: observation.captureId,
114
+ observationId: observation.observationId,
115
+ label: observation.label,
116
+ simulatedDay: observation.simulatedDay,
117
+ referenceFidelity: observation.lanes?.reference?.result?.result?.fidelity,
118
+ treatedFidelity: observation.lanes?.treated?.result?.result?.fidelity,
119
+ diffEntries: observation.diff?.truncation?.originalEntries ?? observation.diff?.entries?.length ?? 0,
120
+ diffTruncated: observation.diff?.truncation?.truncated === true,
121
+ })) : [];
122
+ return secretSafe({
123
+ schemaVersion: value.schemaVersion,
124
+ version: value.version,
125
+ checkpointId: value.checkpointId,
126
+ attemptId: value.attemptId,
127
+ sequence: value.sequence,
128
+ trigger: value.trigger,
129
+ simulatedDay: value.simulatedDay,
130
+ simulatedDate: value.simulatedDate,
131
+ throughCallSequence: value.throughCallSequence,
132
+ actionLogVersion: value.actionLogVersion,
133
+ treatedStateDigest: value.treatedStateDigest,
134
+ metrics: value.metrics,
135
+ cursors: value.cursors,
136
+ observationCount: observations.length,
137
+ ...(observations.length ? { observations } : {}),
138
+ createdAt: value.createdAt,
139
+ });
140
+ }
141
+
142
+ function receiptProjection(value) {
143
+ const attempt = value?.attempt && typeof value.attempt === 'object' ? value.attempt : {};
144
+ const practiceRun = attempt.practiceRun ?? value?.practiceRun ?? null;
145
+ const calls = Array.isArray(value?.events?.calls) ? value.events.calls : [];
146
+ const actions = Array.isArray(value?.events?.actions) ? value.events.actions : [];
147
+ const assertions = Array.isArray(value?.receiptEvidence?.assertions)
148
+ ? value.receiptEvidence.assertions.slice(0, 50).map((assertion) => ({
149
+ id: assertion.id,
150
+ status: assertion.status,
151
+ label: assertion.label,
152
+ }))
153
+ : [];
154
+ const verdict = value?.report?.verdict && typeof value.report.verdict === 'object'
155
+ ? Object.fromEntries(['status', 'outcome', 'grade', 'score', 'headline', 'horizonDays']
156
+ .filter((key) => value.report.verdict[key] !== undefined)
157
+ .map((key) => [key, value.report.verdict[key]]))
158
+ : undefined;
159
+ return secretSafe({
160
+ schemaVersion: value?.schemaVersion,
161
+ attemptId: attempt.attemptId ?? practiceRun?.attemptId,
162
+ practiceRun,
163
+ evidenceStatus: practiceRun?.evidenceStatus,
164
+ recordedAt: value?.recordedAt ?? attempt.recordedAt,
165
+ clock: value?.clock,
166
+ summary: {
167
+ calls: calls.length,
168
+ actions: actions.length,
169
+ rejectedCalls: calls.filter((call) => call?.rejected === true || Number(call?.status) >= 400).length,
170
+ acceptedWrites: calls.filter((call) => Number(call?.actionCount ?? 0) > 0).length,
171
+ },
172
+ receipt: {
173
+ summary: value?.receiptEvidence?.summary,
174
+ assertions,
175
+ },
176
+ ...(verdict ? { verdict } : {}),
177
+ });
178
+ }
179
+
180
+ export function createTools(config) {
181
+ const apiBaseUrl = String(config.apiBaseUrl ?? '').replace(/\/+$/, '');
182
+ const controlApiKey = config.controlApiKey ?? '';
183
+ const dashboardUrl = String(config.dashboardUrl ?? '').replace(/\/+$/, '');
184
+ const practiceApiToken = String(config.practiceApiToken ?? '').trim();
185
+ const fetchImpl = config.fetchImpl ?? fetch;
186
+
187
+ if (!apiBaseUrl) throw new Error('MEGURO_API_BASE_URL is required');
188
+
189
+ function controlAuthHeaders() {
190
+ if (practiceApiToken.startsWith('meg_sk_')) return { authorization: `Bearer ${practiceApiToken}` };
191
+ if (controlApiKey) return { 'x-meguro-key': controlApiKey };
192
+ throw new Error('MEGURO_API_TOKEN (meg_sk account key) or MEGURO_CONTROL_API_KEY is required for this tool');
193
+ }
194
+
195
+ async function api(method, path, body, options = {}) {
196
+ const headers = { accept: 'application/json' };
197
+ const needsControlCredential = Boolean(options.auth || method !== 'GET');
198
+ if (needsControlCredential) Object.assign(headers, controlAuthHeaders());
199
+ if (method !== 'GET') {
200
+ headers['content-type'] = 'application/json';
201
+ }
202
+ // Cache-bust reads: status URLs sit behind caches that have lied to us before.
203
+ const bust = method === 'GET' ? `${path.includes('?') ? '&' : '?'}_=${Date.now()}` : '';
204
+ const response = await fetchImpl(`${apiBaseUrl}${path}${bust}`, {
205
+ method,
206
+ headers,
207
+ ...(body ? { body: JSON.stringify(body) } : {}),
208
+ });
209
+ const text = await response.text();
210
+ let json;
211
+ try { json = JSON.parse(text); } catch { json = { raw: text }; }
212
+ if (!response.ok) {
213
+ const message = json.errors?.[0]?.message ?? json.error ?? text.slice(0, 300);
214
+ throw new Error(`${response.status} ${path}: ${message}`);
215
+ }
216
+ return json;
217
+ }
218
+
219
+ // Like `api`, but returns { ok, status, json } instead of throwing on !ok — so a structured control-route
220
+ // error body (e.g. the meguro.admin-probe.v1 error envelope) can be preserved verbatim to the caller.
221
+ async function apiRaw(method, path, body) {
222
+ const headers = { accept: 'application/json', ...controlAuthHeaders() };
223
+ if (method !== 'GET') headers['content-type'] = 'application/json';
224
+ const response = await fetchImpl(`${apiBaseUrl}${path}`, {
225
+ method,
226
+ headers,
227
+ ...(body ? { body: JSON.stringify(body) } : {}),
228
+ });
229
+ const text = await response.text();
230
+ let json;
231
+ try { json = JSON.parse(text); } catch { json = { raw: text }; }
232
+ return { ok: response.ok, status: response.status, json };
233
+ }
234
+
235
+ async function practiceApi(method, path, body) {
236
+ if (!practiceApiToken) throw new Error('MEGURO_API_TOKEN is required for practice-run tools');
237
+ const headers = { accept: 'application/json', authorization: `Bearer ${practiceApiToken}` };
238
+ if (method !== 'GET') headers['content-type'] = 'application/json';
239
+ let response;
240
+ try {
241
+ response = await fetchImpl(`${apiBaseUrl}${path}`, {
242
+ method,
243
+ headers,
244
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
245
+ });
246
+ } catch (error) {
247
+ throw new Error(scrubString(error instanceof Error ? error.message : String(error)));
248
+ }
249
+ const text = await response.text();
250
+ let json;
251
+ try { json = text ? JSON.parse(text) : null; } catch { json = { error: { message: 'Meguro returned a non-JSON response.' } }; }
252
+ const retryHeader = response.headers?.get?.('retry-after');
253
+ const retryAfterSeconds = retryHeader === null || retryHeader === undefined || retryHeader === '' ? undefined : Number(retryHeader);
254
+ return { ok: response.ok, status: response.status, json, retryAfterSeconds };
255
+ }
256
+
257
+ function dashboardLink(runId) {
258
+ if (!dashboardUrl) return undefined;
259
+ return `${dashboardUrl}/?api=${encodeURIComponent(apiBaseUrl)}&run=${encodeURIComponent(runId)}`;
260
+ }
261
+
262
+ // MEG-34: map a minted mcp-probe reference to a Console URL that reopens the exact historical probe. The
263
+ // URL carries only the opaque reference — never the query, variables, token, result, worldId, or
264
+ // operation name. If MEGURO_DASHBOARD_URL is unset the probe still succeeds; consoleUrl is null.
265
+ function consoleUrlFields(consoleRef, consoleRefExpiresAt) {
266
+ if (!consoleRef) return { consoleUrl: null };
267
+ if (!dashboardUrl) return { consoleUrl: null, consoleUrlError: 'MEGURO_DASHBOARD_URL is not configured' };
268
+ return {
269
+ consoleUrl: `${dashboardUrl}/index.html#console?ref=${encodeURIComponent(consoleRef)}`,
270
+ ...(consoleRefExpiresAt ? { consoleUrlExpiresAt: consoleRefExpiresAt } : {}),
271
+ };
272
+ }
273
+
274
+ const definitions = [
275
+ {
276
+ name: 'run_start',
277
+ description: 'Start a new Meguro store: writes `months` of believable, dated commerce history onto the configured real dev store (the fastest backdating run — the past is data). Deterministic by seed: same seed, same year. Requires typing the shop domain in confirm_shop (safety ritual). Returns runId, plan size, estimated wall-clock, and a dashboard link to watch the store being created.',
278
+ inputSchema: {
279
+ type: 'object',
280
+ properties: {
281
+ months: { type: 'integer', minimum: 1, maximum: 25, description: 'How many months of history to write (12 = a full seasonal year incl. BFCM).' },
282
+ confirm_shop: { type: 'string', description: 'The exact myshopify.com domain of the target dev store, typed out — the SafetyGate confirmation.' },
283
+ seed: { type: 'string', description: 'The store seed — any string; yours. Same seed, same year, every time. Omit to use the server default.' },
284
+ today: { type: 'string', description: 'ISO date (YYYY-MM-DD) the history ends at. Defaults to the server\'s today.' },
285
+ catalog: {
286
+ type: 'array',
287
+ description: 'Optional product fixtures to write the history across. Omit to use the run\'s configured variant/catalog.',
288
+ items: { type: 'object' },
289
+ },
290
+ },
291
+ required: ['months', 'confirm_shop'],
292
+ },
293
+ },
294
+ {
295
+ name: 'run_status',
296
+ description: 'Live progress of a run: orders written / total, sim-date watermark, status, lastError. Account-key-gated, read-only, cache-busted. Use this instead of guessing.',
297
+ inputSchema: {
298
+ type: 'object',
299
+ properties: { runId: { type: 'string', description: 'The run id, e.g. cultivate-2026-06-10t15-51-31-737z' } },
300
+ required: ['runId'],
301
+ },
302
+ },
303
+ {
304
+ name: 'run_ledger',
305
+ description: 'The run\'s ledger — its complete attributed diary (every event, every actor). Account-key-gated and read-only.',
306
+ inputSchema: {
307
+ type: 'object',
308
+ properties: {
309
+ runId: { type: 'string' },
310
+ limit: { type: 'integer', minimum: 1, maximum: 500, description: 'Max entries (default 50).' },
311
+ },
312
+ required: ['runId'],
313
+ },
314
+ },
315
+ {
316
+ name: 'run_report',
317
+ description: 'Generate and fetch the run\'s receipt (verdict, summary, S3 keys). The receipt never claims more than the ledger can prove.',
318
+ inputSchema: {
319
+ type: 'object',
320
+ properties: { runId: { type: 'string' } },
321
+ required: ['runId'],
322
+ },
323
+ },
324
+ {
325
+ name: 'run_resume',
326
+ description: 'Resume a failed Meguro run after raising budgets. Mutates the real run control plane, so it requires an account API key. Use it for budget-stopped stores, not completed history.',
327
+ inputSchema: {
328
+ type: 'object',
329
+ properties: {
330
+ runId: { type: 'string' },
331
+ maxEvents: { type: 'integer', minimum: 1, description: 'Optional replacement events budget.' },
332
+ maxShopifyApiCalls: { type: 'integer', minimum: 1, description: 'Optional replacement Shopify API-call budget.' },
333
+ },
334
+ required: ['runId'],
335
+ },
336
+ },
337
+ {
338
+ name: 'runs_diff',
339
+ description: 'Diff two runs of the same store. Verifies the shared prefix, finds the fork, and returns the deltas. "Not comparable" is an honest answer, not an error in your usage.',
340
+ inputSchema: {
341
+ type: 'object',
342
+ properties: {
343
+ a: { type: 'string', description: 'Baseline runId (the twin without the intervention).' },
344
+ b: { type: 'string', description: 'Treatment runId.' },
345
+ },
346
+ required: ['a', 'b'],
347
+ },
348
+ },
349
+ {
350
+ name: 'practice_run_start',
351
+ description: 'Start an external-agent practice run on an existing Meguro practice store. Takes the canonical practice-store id (`storeId`) exactly as returned by get_connection_details, and returns the run identity (`attemptId`) every later practice_run_* tool uses — the store id and the run id are different identifiers. Meguro Console is quick proof, while the agency agent still runs in the caller\'s own environment. Returns the same server-authoritative practice-run contract without connection credentials.',
352
+ inputSchema: {
353
+ type: 'object',
354
+ additionalProperties: false,
355
+ properties: {
356
+ storeId: { type: 'string', minLength: 1, description: 'Canonical Meguro practice-store id (tenant-owned), as returned by get_connection_details.' },
357
+ worldId: { type: 'string', minLength: 1, description: 'Legacy alias for storeId — the same practice-store id under its old name. Prefer storeId.' },
358
+ clock: { type: 'object', description: 'Existing hosted practice-run clock contract.' },
359
+ externalAgent: { type: 'object', description: 'Optional bounded external-agent identity metadata.' },
360
+ observationPlan: { type: 'object', description: 'Optional trusted read-only checkpoint observation plan.' },
361
+ graphqlThrottleMode: { type: 'string', enum: ['off', 'standard-1000', 'plus-2000'], description: 'Optional Meguro compatibility GraphQL throttle mode.' },
362
+ },
363
+ required: [],
364
+ anyOf: [{ required: ['storeId'] }, { required: ['worldId'] }],
365
+ },
366
+ },
367
+ {
368
+ name: 'practice_run_status',
369
+ description: 'Read the server-authoritative state of an external-agent practice run. Use Console for quick proof; the tested agent remains in the caller\'s environment.',
370
+ inputSchema: {
371
+ type: 'object', additionalProperties: false,
372
+ properties: { attemptId: { type: 'string', minLength: 1, description: 'Run identity returned by practice_run_start — not the practice-store id (storeId).' } },
373
+ required: ['attemptId'],
374
+ },
375
+ },
376
+ {
377
+ name: 'practice_run_checkpoint',
378
+ description: 'Capture an immutable checkpoint for an external-agent practice run and return a bounded evidence summary. Console is quick proof; agent execution remains in the caller\'s environment.',
379
+ inputSchema: {
380
+ type: 'object', additionalProperties: false,
381
+ properties: { attemptId: { type: 'string', minLength: 1, description: 'Run identity returned by practice_run_start — not the practice-store id (storeId).' } },
382
+ required: ['attemptId'],
383
+ },
384
+ },
385
+ {
386
+ name: 'practice_run_advance',
387
+ description: 'Advance an external-agent practice run by whole days or until one closed-set commerce condition, using both optimistic concurrency cursors. Console is quick proof; agent execution remains in the caller\'s environment.',
388
+ inputSchema: {
389
+ type: 'object',
390
+ additionalProperties: false,
391
+ properties: {
392
+ attemptId: { type: 'string', minLength: 1, description: 'Run identity returned by practice_run_start — not the practice-store id (storeId).' },
393
+ days: { type: 'integer', minimum: 1, description: 'Whole simulated days to advance.' },
394
+ until: { type: 'object', description: 'Existing meguro.practice-run-until.v1 event-gated request.' },
395
+ expectedDay: { type: 'integer', minimum: 0, description: 'Current practiceRun.time.watermarkDay.' },
396
+ expectedCallSeq: { type: 'integer', minimum: -1, description: 'Current practiceRun.latestCallSequence, or -1 before any call.' },
397
+ },
398
+ required: ['attemptId', 'expectedDay', 'expectedCallSeq'],
399
+ oneOf: [{ required: ['days'] }, { required: ['until'] }],
400
+ },
401
+ },
402
+ {
403
+ name: 'practice_run_finish',
404
+ description: 'Explicitly finish an external-agent practice run without inventing additional time or evidence. Console is quick proof; agent execution remains in the caller\'s environment.',
405
+ inputSchema: {
406
+ type: 'object', additionalProperties: false,
407
+ properties: { attemptId: { type: 'string', minLength: 1, description: 'Run identity returned by practice_run_start — not the practice-store id (storeId).' } },
408
+ required: ['attemptId'],
409
+ },
410
+ },
411
+ {
412
+ name: 'practice_run_report',
413
+ description: 'Read the run\'s receipt — a bounded receipt summary for a completed external-agent practice run. `report` is the protocol/route compatibility name for the receipt (the tool name and the `operation: "report"` field keep it for wire stability); user-facing language is "receipt". Raw private request and response payloads stay out of MCP; use Console for quick proof while the agent remains in the caller\'s environment.',
414
+ inputSchema: {
415
+ type: 'object', additionalProperties: false,
416
+ properties: { attemptId: { type: 'string', minLength: 1, description: 'Run identity returned by practice_run_start — not the practice-store id (storeId).' } },
417
+ required: ['attemptId'],
418
+ },
419
+ },
420
+ {
421
+ name: 'get_connection_details',
422
+ description: 'Fetch the Shopify-shaped Admin GraphQL URL, access token, and shop domain for an existing Meguro practice store so an assistant can configure a user agent without opening the dashboard. Takes the canonical practice-store id (`storeId`) and returns it back, so the result passes directly into practice_run_start({ storeId }) — no identifier translation. The practice-store id is not an `attemptId` (run identity). Requires an account API key because it reveals the per-store token.',
423
+ inputSchema: {
424
+ type: 'object',
425
+ properties: {
426
+ storeId: { type: 'string', description: 'Canonical Meguro practice-store id, e.g. w-abc123. Pass the returned storeId straight into practice_run_start.' },
427
+ worldId: { type: 'string', description: 'Legacy alias for storeId — the same practice-store id under its old name. Prefer storeId.' },
428
+ },
429
+ required: [],
430
+ anyOf: [{ required: ['storeId'] }, { required: ['worldId'] }],
431
+ },
432
+ },
433
+ {
434
+ name: 'admin_probe',
435
+ description: 'Run an isolated Admin GraphQL probe against a Meguro practice store and get the shared meguro.admin-probe.v1 result envelope: fidelity, note, top-level errors, userErrors, cost/throttle, and deterministic truncation. Reads run immediately; a mutation runs only when confirmMutation is true and changes just isolated probe state — never graded runs, receipts, or gate evidence. The per-store token stays server-side and never appears in the result. Also returns a consoleUrl that reopens this exact historical, ungraded probe in the Console without re-running it.',
436
+ inputSchema: {
437
+ type: 'object',
438
+ properties: {
439
+ worldId: { type: 'string', minLength: 1, description: 'Meguro practice store id, e.g. w-abc123.' },
440
+ query: { type: 'string', maxLength: 30000, description: 'The Admin GraphQL query or mutation document to run.' },
441
+ apiVersion: { type: 'string', pattern: '^\\d{4}-\\d{2}$', description: 'Admin API version as YYYY-MM. Defaults to 2026-04.' },
442
+ variables: { type: 'object', description: 'GraphQL variables object (max 20,000 serialized characters).' },
443
+ operationName: { type: 'string', minLength: 1, maxLength: 200, description: 'Which operation to run when the document defines more than one.' },
444
+ confirmMutation: { type: 'boolean', description: 'Set true to run a mutation in isolated probe state. Reads ignore it.' },
445
+ },
446
+ required: ['worldId', 'query'],
447
+ },
448
+ },
449
+ {
450
+ name: 'admin_schema',
451
+ description: 'Look up an exact, bounded slice of the Admin GraphQL surface Meguro models: a type and its direct fields, a Type.field or root mutation with its arguments and return type, or a shared commerce recipe. Deterministic — it never returns the full schema, tenant data, catalog, or credentials, and on a miss it suggests only exact prefix/substring alternatives.',
452
+ inputSchema: {
453
+ type: 'object',
454
+ properties: {
455
+ lookup: { type: 'string', enum: ['type', 'field', 'mutation', 'recipe'], description: 'What to look up: a type, a Type.field, a root mutation, or a commerce recipe.' },
456
+ name: { type: 'string', minLength: 1, maxLength: 200, description: 'Exact name: e.g. ProductVariant, ProductVariant.inventoryQuantity, inventoryAdjustQuantities, or a recipe id/operationName like low-inventory or LowInventory.' },
457
+ apiVersion: { type: 'string', pattern: '^\\d{4}-\\d{2}$', description: 'Admin API version YYYY-MM. Defaults to 2026-04, the only modeled version.' },
458
+ },
459
+ required: ['lookup', 'name'],
460
+ },
461
+ },
462
+ ];
463
+
464
+ async function call(name, args = {}) {
465
+ try {
466
+ switch (name) {
467
+ case 'run_start': {
468
+ const months = Number(args.months);
469
+ const budget = AGED_BUDGET(months);
470
+ const body = {
471
+ mode: 'cultivate',
472
+ scenario: 'aged-history',
473
+ windowMonths: months,
474
+ confirmShop: args.confirm_shop,
475
+ acknowledge: true,
476
+ ...(args.seed ? { seed: args.seed } : {}),
477
+ ...(args.today ? { today: args.today } : {}),
478
+ ...(Array.isArray(args.catalog) && args.catalog.length > 0 ? { catalog: args.catalog } : {}),
479
+ budgets: {
480
+ maxEvents: budget,
481
+ maxShopifyApiCalls: budget,
482
+ maxProductsCreated: Math.max(2, (args.catalog?.length ?? 0) + 1),
483
+ maxVariantsCreated: Math.max(10, (args.catalog?.length ?? 0) + 2),
484
+ maxInventoryMutations: 10,
485
+ },
486
+ };
487
+ const started = await api('POST', '/runs', body);
488
+ return textResult({
489
+ runId: started.runId,
490
+ planLength: started.planLength,
491
+ estimatedWallClockSeconds: started.agedHistoryPlan?.estimatedWallClockSeconds,
492
+ dashboard: dashboardLink(started.runId),
493
+ reminder: 'Enable the cultivate schedule if it is disabled — the run advances on scheduled ticks.',
494
+ });
495
+ }
496
+ case 'run_status': {
497
+ const status = await api('GET', `/runs/${encodeURIComponent(args.runId)}`, undefined, { auth: true });
498
+ return textResult({ ...status, dashboard: dashboardLink(args.runId) });
499
+ }
500
+ case 'run_ledger': {
501
+ const limit = Number.isInteger(args.limit) ? args.limit : 50;
502
+ return textResult(await api('GET', `/runs/${encodeURIComponent(args.runId)}/ledger?limit=${limit}`, undefined, { auth: true }));
503
+ }
504
+ case 'run_report':
505
+ return textResult(await api('GET', `/runs/${encodeURIComponent(args.runId)}/report`, undefined, { auth: true }));
506
+ case 'run_resume': {
507
+ const runId = requiredString(args, 'runId');
508
+ const maxEvents = optionalPositiveInteger(args, 'maxEvents');
509
+ const maxShopifyApiCalls = optionalPositiveInteger(args, 'maxShopifyApiCalls');
510
+ const body = {
511
+ ...(maxEvents === undefined ? {} : { maxEvents }),
512
+ ...(maxShopifyApiCalls === undefined ? {} : { maxShopifyApiCalls }),
513
+ };
514
+ return textResult(await api('POST', `/runs/${encodeURIComponent(runId)}/resume`, body));
515
+ }
516
+ case 'runs_diff':
517
+ return textResult(await api('GET', `/runs/${encodeURIComponent(args.a)}/diff/${encodeURIComponent(args.b)}`, undefined, { auth: true }));
518
+ case 'practice_run_start': {
519
+ const storeId = requiredPracticeStoreId(args);
520
+ const body = {
521
+ purpose: 'external-agent',
522
+ ...(args.clock !== undefined ? { clock: args.clock } : {}),
523
+ ...(args.externalAgent !== undefined ? { externalAgent: args.externalAgent } : {}),
524
+ ...(args.observationPlan !== undefined ? { observationPlan: args.observationPlan } : {}),
525
+ ...(args.graphqlThrottleMode !== undefined ? { graphqlThrottleMode: args.graphqlThrottleMode } : {}),
526
+ };
527
+ const response = await practiceApi('POST', `/practice/stores/${encodeURIComponent(storeId)}/playbacks`, body);
528
+ if (!response.ok) return practiceErrorResult(response.status, response.json, response.retryAfterSeconds);
529
+ const started = response.json ?? {};
530
+ return textResult(secretSafe({
531
+ operation: 'start',
532
+ attemptId: started.attemptId ?? started.practiceRun?.attemptId,
533
+ practiceRun: practiceRunProjection(started.practiceRun),
534
+ paths: {
535
+ state: started.statePath,
536
+ checkpoints: started.checkpointsPath,
537
+ advance: started.advancePath,
538
+ report: started.reportPath,
539
+ },
540
+ ...consoleUrlFields(started.consoleRef, started.consoleRefExpiresAt),
541
+ }));
542
+ }
543
+ case 'practice_run_status': {
544
+ const attemptId = requiredString(args, 'attemptId');
545
+ const response = await practiceApi('GET', `/practice/playbacks/${encodeURIComponent(attemptId)}/state`);
546
+ if (!response.ok) return practiceErrorResult(response.status, response.json, response.retryAfterSeconds);
547
+ const state = response.json ?? {};
548
+ return textResult(secretSafe({
549
+ operation: 'status',
550
+ attemptId,
551
+ practiceRun: practiceRunProjection(state.practiceRun),
552
+ ...consoleUrlFields(state.consoleRef, state.consoleRefExpiresAt),
553
+ }));
554
+ }
555
+ case 'practice_run_checkpoint': {
556
+ const attemptId = requiredString(args, 'attemptId');
557
+ const response = await practiceApi('POST', `/practice/playbacks/${encodeURIComponent(attemptId)}/checkpoint`, {});
558
+ if (!response.ok) return practiceErrorResult(response.status, response.json, response.retryAfterSeconds);
559
+ return textResult(secretSafe({
560
+ operation: 'checkpoint',
561
+ attemptId,
562
+ alreadyCaptured: response.json?.alreadyCaptured === true,
563
+ checkpoint: checkpointProjection(response.json?.checkpoint),
564
+ ...consoleUrlFields(response.json?.consoleRef, response.json?.consoleRefExpiresAt),
565
+ }));
566
+ }
567
+ case 'practice_run_advance': {
568
+ const attemptId = requiredString(args, 'attemptId');
569
+ const hasDays = args.days !== undefined;
570
+ const hasUntil = args.until !== undefined;
571
+ if (hasDays === hasUntil) throw new Error('practice_run_advance requires exactly one of days or until');
572
+ const expectedDay = requiredNonNegativeInteger(args, 'expectedDay');
573
+ const expectedCallSeq = requiredNonNegativeInteger(args, 'expectedCallSeq', -1);
574
+ if (hasDays && (!Number.isSafeInteger(args.days) || args.days < 1)) throw new Error('days must be a positive integer');
575
+ if (hasUntil && (!args.until || typeof args.until !== 'object' || Array.isArray(args.until))) throw new Error('until must be an object');
576
+ const response = await practiceApi('POST', `/practice/playbacks/${encodeURIComponent(attemptId)}/advance`, {
577
+ ...(hasDays ? { days: args.days } : { until: args.until }), expectedDay, expectedCallSeq,
578
+ });
579
+ if (!response.ok) return practiceErrorResult(response.status, response.json, response.retryAfterSeconds);
580
+ const advanced = response.json ?? {};
581
+ return textResult(secretSafe({
582
+ operation: 'advance',
583
+ attemptId,
584
+ watermarkDay: advanced.watermarkDay,
585
+ isComplete: advanced.isComplete,
586
+ alreadyApplied: advanced.alreadyApplied === true,
587
+ ...(advanced.until ? { until: advanced.until } : {}),
588
+ practiceRun: practiceRunProjection(advanced.practiceRun),
589
+ ...consoleUrlFields(advanced.consoleRef, advanced.consoleRefExpiresAt),
590
+ }));
591
+ }
592
+ case 'practice_run_finish': {
593
+ const attemptId = requiredString(args, 'attemptId');
594
+ const response = await practiceApi('POST', `/practice/playbacks/${encodeURIComponent(attemptId)}/finish`, {});
595
+ if (!response.ok) return practiceErrorResult(response.status, response.json, response.retryAfterSeconds);
596
+ return textResult(secretSafe({
597
+ operation: 'finish',
598
+ attemptId,
599
+ practiceRun: practiceRunProjection(response.json),
600
+ ...consoleUrlFields(response.json?.consoleRef, response.json?.consoleRefExpiresAt),
601
+ }));
602
+ }
603
+ case 'practice_run_report': {
604
+ const attemptId = requiredString(args, 'attemptId');
605
+ const response = await practiceApi('GET', `/practice/playbacks/${encodeURIComponent(attemptId)}/report`);
606
+ if (!response.ok) return practiceErrorResult(response.status, response.json, response.retryAfterSeconds);
607
+ return textResult({
608
+ operation: 'report',
609
+ ...receiptProjection(response.json ?? {}),
610
+ ...consoleUrlFields(response.json?.consoleRef, response.json?.consoleRefExpiresAt),
611
+ });
612
+ }
613
+ case 'get_connection_details': {
614
+ const requestedStoreId = requiredPracticeStoreId(args);
615
+ const details = await api('GET', `/practice/${encodeURIComponent(requestedStoreId)}/connection`, undefined, { auth: true });
616
+ const storeId = details.storeId ?? details.worldId ?? requestedStoreId;
617
+ const shopDomain = details.host ?? `${details.worldId ?? requestedStoreId}.meguro.io`;
618
+ return textResult({
619
+ // Canonical practice-store id first: pass it straight into practice_run_start({ storeId }).
620
+ storeId,
621
+ // Legacy alias for the same id, kept for existing callers.
622
+ worldId: details.worldId ?? requestedStoreId,
623
+ shopDomain,
624
+ env: {
625
+ SHOPIFY_ADMIN_GRAPHQL_URL: details.adminUrl,
626
+ SHOPIFY_ADMIN_ACCESS_TOKEN: details.token,
627
+ SHOPIFY_SHOP_DOMAIN: shopDomain,
628
+ },
629
+ });
630
+ }
631
+ case 'admin_probe': {
632
+ const worldId = requiredWorldId(args);
633
+ const body = {
634
+ query: typeof args.query === 'string' ? args.query : '',
635
+ ...(args.apiVersion !== undefined ? { apiVersion: args.apiVersion } : {}),
636
+ ...(args.variables !== undefined ? { variables: args.variables } : {}),
637
+ ...(args.operationName !== undefined ? { operationName: args.operationName } : {}),
638
+ ...(args.confirmMutation !== undefined ? { confirmMutation: args.confirmMutation } : {}),
639
+ // MEG-34: always ask the server to mint a durable Console reference for a successful probe.
640
+ createConsoleReference: true,
641
+ };
642
+ const { ok, json } = await apiRaw('POST', `/practice/${encodeURIComponent(worldId)}/probe/admin`, body);
643
+ if (ok) {
644
+ // Replace the internal reference id with a ready-to-open Console URL (opaque ref only).
645
+ const { consoleRef, consoleRefExpiresAt, ...envelope } = json;
646
+ return textResult({ ...envelope, ...consoleUrlFields(consoleRef, consoleRefExpiresAt) });
647
+ }
648
+ // Preserve the structured meguro.admin-probe.v1 error body (code + message) with isError: true,
649
+ // instead of collapsing it to an unstructured thrown message.
650
+ return { content: [{ type: 'text', text: JSON.stringify(secretSafe(json), null, 2) }], isError: true };
651
+ }
652
+ case 'admin_schema': {
653
+ const lookup = requiredString(args, 'lookup');
654
+ const lookupName = requiredString(args, 'name');
655
+ const body = {
656
+ lookup,
657
+ name: lookupName,
658
+ ...(args.apiVersion !== undefined ? { apiVersion: args.apiVersion } : {}),
659
+ };
660
+ return textResult(await api('POST', '/practice/admin-schema', body, { auth: true }));
661
+ }
662
+ default:
663
+ return errorResult(`Unknown tool: ${name}`);
664
+ }
665
+ } catch (error) {
666
+ return errorResult(error instanceof Error ? error.message : String(error));
667
+ }
668
+ }
669
+
670
+ return { definitions, call };
671
+ }