@martinloop/mcp 0.1.1 → 0.1.2

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.
Files changed (35) hide show
  1. package/README.md +136 -38
  2. package/dist/server-validation.d.ts +10 -0
  3. package/dist/server-validation.js +234 -0
  4. package/dist/server.js +42 -13
  5. package/dist/tools/get-status.d.ts +10 -2
  6. package/dist/tools/get-status.js +11 -4
  7. package/dist/tools/inspect-loop.d.ts +4 -2
  8. package/dist/tools/inspect-loop.js +4 -7
  9. package/dist/tools/run-loop.d.ts +2 -0
  10. package/dist/tools/run-loop.js +10 -3
  11. package/dist/tools/run-store.d.ts +20 -0
  12. package/dist/tools/run-store.js +109 -0
  13. package/dist/vendor/adapters/claude-cli.d.ts +19 -4
  14. package/dist/vendor/adapters/claude-cli.js +55 -24
  15. package/dist/vendor/adapters/cli-bridge.d.ts +1 -0
  16. package/dist/vendor/adapters/cli-bridge.js +154 -28
  17. package/dist/vendor/adapters/index.d.ts +1 -0
  18. package/dist/vendor/adapters/index.js +1 -0
  19. package/dist/vendor/adapters/verifier-only.d.ts +7 -0
  20. package/dist/vendor/adapters/verifier-only.js +57 -0
  21. package/dist/vendor/contracts/index.d.ts +3 -1
  22. package/dist/vendor/core/compiler.d.ts +2 -0
  23. package/dist/vendor/core/compiler.js +10 -4
  24. package/dist/vendor/core/context-integrity.d.ts +26 -0
  25. package/dist/vendor/core/context-integrity.js +56 -0
  26. package/dist/vendor/core/index.d.ts +7 -4
  27. package/dist/vendor/core/index.js +222 -64
  28. package/dist/vendor/core/persistence/index.d.ts +2 -0
  29. package/dist/vendor/core/persistence/index.js +1 -0
  30. package/dist/vendor/core/persistence/runs-reader.d.ts +52 -0
  31. package/dist/vendor/core/persistence/runs-reader.js +84 -0
  32. package/dist/vendor/core/persistence/store.d.ts +6 -1
  33. package/dist/vendor/core/persistence/store.js +5 -0
  34. package/dist/vendor/core/policy.d.ts +6 -0
  35. package/package.json +13 -5
package/README.md CHANGED
@@ -1,15 +1,24 @@
1
1
  # @martinloop/mcp
2
-
3
- Martin Loop's installable Model Context Protocol server.
4
-
5
- It exposes three MCP tools over stdio:
6
-
7
- - `martin_run`
8
- - `martin_inspect`
9
- - `martin_status`
10
-
11
- ## Quickstart
12
-
2
+
3
+ Governed MCP server for AI coding agents with hard budgets, verifier gates, policy checks, and inspectable run records.
4
+
5
+ Martin Loop helps MCP hosts run AI coding work inside a bounded runtime instead of an open-ended retry loop. The standalone MCP package exposes three focused tools over stdio:
6
+
7
+ - `martin_run`
8
+ - `martin_inspect`
9
+ - `martin_status`
10
+
11
+ ## What's new in 0.1.2
12
+
13
+ - `martin_inspect` now reads both canonical `loop-record.json` runs and legacy `.jsonl` run-store files
14
+ - `martin_status` now supports `file`, `loopId`, `runsDir`, and `latest` selectors in addition to inline `loopJson`
15
+ - `martin_run` now persists loop records by default in the MCP path and preserves `allowedPaths`, `deniedPaths`, and resolved `repoRoot`
16
+ - the packaged tarball now rebuilds vendored workspace dependencies before packing, so `npm` installs match current source instead of stale `dist/` output
17
+ - the packaged artifact now rebuilds and vendors the Martin runtime dependencies needed by the standalone MCP server
18
+ - release validation now includes both a packed-tarball smoke and a published-artifact smoke
19
+
20
+ ## Quickstart
21
+
13
22
  Run the packaged server directly:
14
23
 
15
24
  ```sh
@@ -26,34 +35,123 @@ claude mcp add --scope user martin-loop -- npx @martinloop/mcp
26
35
  claude mcp add --scope user martin-loop cmd /c "npx @martinloop/mcp"
27
36
  ```
28
37
 
29
- For clients that want explicit command/args:
38
+ Generic stdio configuration for non-Claude clients:
39
+
40
+ ```json
41
+ {
42
+ "type": "stdio",
43
+ "command": "npx",
44
+ "args": ["@martinloop/mcp"]
45
+ }
46
+ ```
47
+
48
+ ## What the tools do
49
+
50
+ - `martin_run`: runs a governed coding loop with budget caps, verifier commands, engine selection, path scoping, and persisted loop records
51
+ - `martin_inspect`: reads Martin Loop run-store data from a canonical `loop-record.json`, a legacy `.jsonl` file, or a full runs directory
52
+ - `martin_status`: evaluates remaining budget pressure and stop conditions from inline JSON, a saved loop record, a loop id, or the latest persisted run
53
+
54
+ ## Requirements
55
+
56
+ - Node 20+
57
+ - For live runs, either the `claude` CLI or the `codex` CLI must be on `PATH`
58
+ - For dry-run and smoke-test flows, set `MARTIN_LIVE=false`
59
+
60
+ Live `martin_run` delegates to the configured CLI adapter. If no supported CLI is installed, use the stub path for testing:
61
+
62
+ ```sh
63
+ MARTIN_LIVE=false npx @martinloop/mcp
64
+ ```
65
+
66
+ ## Tool examples
67
+
68
+ ### `martin_run`
69
+
70
+ Example request body:
71
+
72
+ ```json
73
+ {
74
+ "objective": "Fix the auth regression and prove it with tests",
75
+ "engine": "codex",
76
+ "budgetUsd": 3,
77
+ "softLimitUsd": 2.25,
78
+ "maxIterations": 3,
79
+ "verificationPlan": ["pnpm test --filter auth"],
80
+ "workingDirectory": ".",
81
+ "allowedPaths": ["src/**", "tests/**"],
82
+ "deniedPaths": [".env*", "secrets/**"]
83
+ }
84
+ ```
85
+
86
+ ### `martin_inspect`
87
+
88
+ Inspect the default run store:
89
+
90
+ ```json
91
+ {}
92
+ ```
93
+
94
+ Inspect a legacy JSONL file directly:
95
+
96
+ ```json
97
+ {
98
+ "file": "C:/Users/you/.martin/runs/workspace.jsonl"
99
+ }
100
+ ```
101
+
102
+ Inspect a canonical runs directory:
103
+
104
+ ```json
105
+ {
106
+ "runsDir": "C:/Users/you/.martin/runs"
107
+ }
108
+ ```
109
+
110
+ ### `martin_status`
111
+
112
+ Status for the latest saved run:
113
+
114
+ ```json
115
+ {
116
+ "latest": true
117
+ }
118
+ ```
119
+
120
+ Status for a specific persisted loop:
121
+
122
+ ```json
123
+ {
124
+ "loopId": "loop-123",
125
+ "runsDir": "C:/Users/you/.martin/runs"
126
+ }
127
+ ```
128
+
129
+ ## Official MCP Registry
130
+
131
+ This package is prepared for the official MCP Registry metadata flow:
30
132
 
31
- - Command: `npx`
32
- - Args: `@martinloop/mcp`
33
-
34
- ## Official MCP Registry
35
-
36
- This package is prepared for the official MCP Registry metadata flow:
37
-
38
133
  - npm package: `@martinloop/mcp`
39
- - registry server name: `io.github.keesan12/martin-loop`
40
- - manifest file: `packages/mcp/server.json`
41
-
42
- The official registry publish flow is separate from npm publication. After publishing the package to npm, run the publisher from `packages/mcp`:
43
-
44
- ```sh
45
- mcp-publisher login github
46
- mcp-publisher publish
47
- ```
48
-
49
- ## Local Verification
50
-
51
- From the repository root:
52
-
53
- ```sh
54
- pnpm --filter @martinloop/mcp build
134
+ - registry server name: `io.github.keesan12/martin-loop`
135
+ - manifest file: `packages/mcp/server.json`
136
+
137
+ The official registry publish flow is separate from npm publication. After publishing the package to npm, run the publisher from `packages/mcp`:
138
+
139
+ ```sh
140
+ mcp-publisher login github
141
+ mcp-publisher publish
142
+ ```
143
+
144
+ ## Local verification
145
+
146
+ From the repository root:
147
+
148
+ ```sh
149
+ pnpm --filter @martinloop/mcp lint
55
150
  pnpm --filter @martinloop/mcp test
151
+ pnpm --filter @martinloop/mcp build
56
152
  pnpm --filter @martinloop/mcp smoke:pack
57
- ```
58
-
59
- `smoke:pack` packs the tarball, launches it through `npx`, performs the MCP handshake, lists tools, and verifies a `martin_status` call.
153
+ pnpm --filter @martinloop/mcp smoke:published
154
+ ```
155
+
156
+ - `smoke:pack` validates the local packed tarball before publish
157
+ - `smoke:published` validates the npm-published artifact through `npm exec`
@@ -0,0 +1,10 @@
1
+ type ToolName = "martin_run" | "martin_inspect" | "martin_status";
2
+ export declare function validateToolInput(name: ToolName, args: unknown): unknown;
3
+ export declare function sanitizeToolErrorMessage(error: unknown): string;
4
+ export declare function resolveSafeRepoRoot(repoRoot?: string, workspaceRoot?: string): string;
5
+ export declare function resolveSafeRunsJsonPath(file: string, runsRoot?: string): string;
6
+ export declare function resolveSafeRunsPath(file: string, runsRoot?: string): string;
7
+ export declare function resolveSafeRunsRootPath(runsRoot?: string, fallbackRunsRoot?: string): string;
8
+ export declare function resolveSafeLoopRecordPath(loopId: string, runsRoot?: string): string;
9
+ export declare function normalizeSafePathPatterns(value: unknown, name: string): string[] | undefined;
10
+ export {};
@@ -0,0 +1,234 @@
1
+ import { extname, isAbsolute, relative, resolve } from "node:path";
2
+ import { resolveRunsRoot } from "./vendor/core/index.js";
3
+ export function validateToolInput(name, args) {
4
+ switch (name) {
5
+ case "martin_run":
6
+ return validateRunInput(args);
7
+ case "martin_inspect":
8
+ return validateInspectInput(args);
9
+ case "martin_status":
10
+ return validateStatusInput(args);
11
+ default:
12
+ throw new Error(`Unknown tool: ${name}`);
13
+ }
14
+ }
15
+ export function sanitizeToolErrorMessage(error) {
16
+ const message = error instanceof Error ? error.message : String(error);
17
+ return /([A-Za-z]:\\|\/|policy\.rego|policy\.wasm|\.pem|\.env)/u.test(message)
18
+ ? "Tool execution failed."
19
+ : message;
20
+ }
21
+ export function resolveSafeRepoRoot(repoRoot, workspaceRoot = process.env.MARTIN_MCP_WORKSPACE_ROOT ?? process.cwd()) {
22
+ const baseRoot = resolve(workspaceRoot);
23
+ const candidate = repoRoot ? resolve(baseRoot, repoRoot) : baseRoot;
24
+ assertPathWithinRoot(candidate, baseRoot, "workingDirectory");
25
+ return candidate;
26
+ }
27
+ export function resolveSafeRunsJsonPath(file, runsRoot = resolveRunsRoot(process.env)) {
28
+ const baseRoot = resolve(runsRoot);
29
+ const candidate = resolve(baseRoot, file);
30
+ assertPathWithinRoot(candidate, baseRoot, "file");
31
+ const extension = extname(candidate).toLowerCase();
32
+ if (extension !== ".json" && extension !== ".jsonl") {
33
+ throw new Error("Invalid file.");
34
+ }
35
+ return candidate;
36
+ }
37
+ export function resolveSafeRunsPath(file, runsRoot = resolveRunsRoot(process.env)) {
38
+ const baseRoot = resolve(runsRoot);
39
+ const candidate = resolve(baseRoot, file);
40
+ assertPathWithinRoot(candidate, baseRoot, "file");
41
+ const extension = extname(candidate).toLowerCase();
42
+ if (extension && extension !== ".json" && extension !== ".jsonl") {
43
+ throw new Error("Invalid file.");
44
+ }
45
+ return candidate;
46
+ }
47
+ export function resolveSafeRunsRootPath(runsRoot, fallbackRunsRoot = resolveRunsRoot(process.env)) {
48
+ const baseRoot = resolve(fallbackRunsRoot);
49
+ const candidate = runsRoot ? resolve(baseRoot, runsRoot) : baseRoot;
50
+ assertPathWithinRoot(candidate, baseRoot, "runsDir");
51
+ return candidate;
52
+ }
53
+ export function resolveSafeLoopRecordPath(loopId, runsRoot = resolveRunsRoot(process.env)) {
54
+ const normalizedLoopId = requireLoopId(loopId, "loopId");
55
+ return resolveSafeRunsJsonPath(`${normalizedLoopId}/loop-record.json`, runsRoot);
56
+ }
57
+ export function normalizeSafePathPatterns(value, name) {
58
+ const paths = optionalStringArray(value, name);
59
+ if (!paths) {
60
+ return undefined;
61
+ }
62
+ return paths.map((pattern) => {
63
+ const normalized = pattern.replace(/\\/gu, "/").trim();
64
+ if (normalized.length === 0 ||
65
+ normalized.startsWith("/") ||
66
+ /^[A-Za-z]:\//u.test(normalized) ||
67
+ normalized.split("/").includes("..")) {
68
+ throw new Error(`Invalid ${name}.`);
69
+ }
70
+ return normalized;
71
+ });
72
+ }
73
+ function validateRunInput(args) {
74
+ const record = requireObject(args);
75
+ assertAllowedKeys(record, [
76
+ "objective",
77
+ "workingDirectory",
78
+ "engine",
79
+ "model",
80
+ "maxUsd",
81
+ "maxIterations",
82
+ "maxTokens",
83
+ "verificationPlan",
84
+ "allowedPaths",
85
+ "deniedPaths",
86
+ "workspaceId",
87
+ "projectId"
88
+ ]);
89
+ const engine = optionalEnum(record.engine, "engine", ["claude", "codex"]);
90
+ return {
91
+ objective: requireString(record.objective, "objective"),
92
+ ...(record.workingDirectory !== undefined
93
+ ? { workingDirectory: resolveSafeRepoRoot(requireString(record.workingDirectory, "workingDirectory")) }
94
+ : {}),
95
+ ...(engine ? { engine } : {}),
96
+ ...optionalString(record.model, "model"),
97
+ ...optionalPositiveNumber(record.maxUsd, "maxUsd"),
98
+ ...optionalPositiveInteger(record.maxIterations, "maxIterations"),
99
+ ...optionalPositiveInteger(record.maxTokens, "maxTokens"),
100
+ ...optionalStringArrayAsObject(record.verificationPlan, "verificationPlan"),
101
+ ...optionalPathPatternArrayAsObject(record.allowedPaths, "allowedPaths"),
102
+ ...optionalPathPatternArrayAsObject(record.deniedPaths, "deniedPaths"),
103
+ ...optionalString(record.workspaceId, "workspaceId"),
104
+ ...optionalString(record.projectId, "projectId")
105
+ };
106
+ }
107
+ function validateInspectInput(args) {
108
+ const record = requireObject(args);
109
+ assertAllowedKeys(record, ["file", "runsDir"]);
110
+ return {
111
+ ...(record.file !== undefined
112
+ ? { file: resolveSafeRunsPath(requireString(record.file, "file")) }
113
+ : {}),
114
+ ...(record.runsDir !== undefined
115
+ ? { runsDir: resolveSafeRunsRootPath(requireString(record.runsDir, "runsDir")) }
116
+ : {})
117
+ };
118
+ }
119
+ function validateStatusInput(args) {
120
+ const record = requireObject(args);
121
+ assertAllowedKeys(record, ["loopJson", "file", "loopId", "runsDir", "latest"]);
122
+ const selectors = [
123
+ record.loopJson !== undefined ? "loopJson" : null,
124
+ record.file !== undefined ? "file" : null,
125
+ record.loopId !== undefined ? "loopId" : null,
126
+ record.latest !== undefined ? "latest" : null
127
+ ].filter((value) => value !== null);
128
+ if (selectors.length !== 1) {
129
+ throw new Error("Provide exactly one of loopJson, file, loopId, or latest.");
130
+ }
131
+ if (record.latest !== undefined && record.latest !== true) {
132
+ throw new Error("Invalid latest.");
133
+ }
134
+ return {
135
+ ...(record.loopJson !== undefined
136
+ ? { loopJson: requireString(record.loopJson, "loopJson") }
137
+ : {}),
138
+ ...(record.file !== undefined
139
+ ? { file: resolveSafeRunsPath(requireString(record.file, "file")) }
140
+ : {}),
141
+ ...(record.loopId !== undefined
142
+ ? { loopId: requireLoopId(record.loopId, "loopId") }
143
+ : {}),
144
+ ...(record.runsDir !== undefined
145
+ ? { runsDir: resolveSafeRunsRootPath(requireString(record.runsDir, "runsDir")) }
146
+ : {}),
147
+ ...(record.latest === true ? { latest: true } : {})
148
+ };
149
+ }
150
+ function requireObject(value) {
151
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
152
+ throw new Error("Tool arguments must be an object.");
153
+ }
154
+ return value;
155
+ }
156
+ function assertAllowedKeys(record, allowed) {
157
+ const unknownKeys = Object.keys(record).filter((key) => !allowed.includes(key));
158
+ if (unknownKeys.length > 0) {
159
+ throw new Error(`Unknown arguments: ${unknownKeys.join(", ")}`);
160
+ }
161
+ }
162
+ function assertPathWithinRoot(candidatePath, rootPath, name) {
163
+ const relativePath = relative(rootPath, candidatePath);
164
+ if (relativePath === "" || relativePath === ".") {
165
+ return;
166
+ }
167
+ if (relativePath.startsWith("..") || isAbsolute(relativePath)) {
168
+ throw new Error(`Invalid ${name}.`);
169
+ }
170
+ }
171
+ function requireString(value, name) {
172
+ if (typeof value !== "string" || value.trim().length === 0) {
173
+ throw new Error(`Invalid ${name}.`);
174
+ }
175
+ return value.trim();
176
+ }
177
+ function requireLoopId(value, name) {
178
+ const loopId = requireString(value, name);
179
+ if (!/^[A-Za-z0-9._-]+$/u.test(loopId)) {
180
+ throw new Error(`Invalid ${name}.`);
181
+ }
182
+ return loopId;
183
+ }
184
+ function optionalString(value, name) {
185
+ if (value === undefined) {
186
+ return {};
187
+ }
188
+ return { [name]: requireString(value, name) };
189
+ }
190
+ function optionalPositiveNumber(value, name) {
191
+ if (value === undefined) {
192
+ return {};
193
+ }
194
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
195
+ throw new Error(`Invalid ${name}.`);
196
+ }
197
+ return { [name]: value };
198
+ }
199
+ function optionalPositiveInteger(value, name) {
200
+ if (value === undefined) {
201
+ return {};
202
+ }
203
+ if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
204
+ throw new Error(`Invalid ${name}.`);
205
+ }
206
+ return { [name]: value };
207
+ }
208
+ function optionalStringArray(value, name) {
209
+ if (value === undefined) {
210
+ return undefined;
211
+ }
212
+ if (!Array.isArray(value)) {
213
+ throw new Error(`Invalid ${name}.`);
214
+ }
215
+ return value.map((item) => requireString(item, name));
216
+ }
217
+ function optionalStringArrayAsObject(value, name) {
218
+ const values = optionalStringArray(value, name);
219
+ return values ? { [name]: values } : {};
220
+ }
221
+ function optionalPathPatternArrayAsObject(value, name) {
222
+ const values = normalizeSafePathPatterns(value, name);
223
+ return values ? { [name]: values } : {};
224
+ }
225
+ function optionalEnum(value, name, allowed) {
226
+ if (value === undefined) {
227
+ return undefined;
228
+ }
229
+ if (typeof value !== "string" || !allowed.includes(value)) {
230
+ throw new Error(`Invalid ${name}.`);
231
+ }
232
+ return value;
233
+ }
234
+ //# sourceMappingURL=server-validation.js.map
package/dist/server.js CHANGED
@@ -23,7 +23,8 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprot
23
23
  import { getStatusTool } from "./tools/get-status.js";
24
24
  import { inspectLoopTool } from "./tools/inspect-loop.js";
25
25
  import { runLoopTool } from "./tools/run-loop.js";
26
- const server = new Server({ name: "martin-loop", version: "0.1.1" }, { capabilities: { tools: {} } });
26
+ import { sanitizeToolErrorMessage, validateToolInput } from "./server-validation.js";
27
+ const server = new Server({ name: "martin-loop", version: "0.1.2" }, { capabilities: { tools: {} } });
27
28
  // ---------------------------------------------------------------------------
28
29
  // Tool manifest
29
30
  // ---------------------------------------------------------------------------
@@ -69,6 +70,16 @@ server.setRequestHandler(ListToolsRequestSchema, () => ({
69
70
  items: { type: "string" },
70
71
  description: "Shell commands that must all exit 0 for the task to be considered complete (e.g. ['pnpm test', 'pnpm build'])."
71
72
  },
73
+ allowedPaths: {
74
+ type: "array",
75
+ items: { type: "string" },
76
+ description: "Relative path globs Martin may modify, such as ['src/**', 'tests/**']."
77
+ },
78
+ deniedPaths: {
79
+ type: "array",
80
+ items: { type: "string" },
81
+ description: "Relative path globs Martin must never modify, such as ['.env', 'docs/security/**']."
82
+ },
72
83
  workspaceId: {
73
84
  type: "string",
74
85
  description: "Workspace identifier for telemetry. Defaults to 'ws_mcp'."
@@ -83,30 +94,48 @@ server.setRequestHandler(ListToolsRequestSchema, () => ({
83
94
  },
84
95
  {
85
96
  name: "martin_inspect",
86
- description: "Summarise a saved Martin loop record file. Reads a JSON file containing one or more LoopRecords and returns portfolio-level statistics: total spend, avoided spend, token counts, and loop counts.",
97
+ description: "Summarise Martin Loop run records from a saved loop file or run-store directory. Supports canonical loop-record.json files, legacy JSONL files, and full runs directories.",
87
98
  inputSchema: {
88
99
  type: "object",
89
100
  properties: {
90
101
  file: {
91
102
  type: "string",
92
- description: "Absolute or relative path to a LoopRecord JSON file."
103
+ description: "Optional path under the Martin runs root to a loop-record.json file, a legacy .jsonl file, or a run-store directory."
104
+ },
105
+ runsDir: {
106
+ type: "string",
107
+ description: "Optional Martin runs directory. Defaults to MARTIN_RUNS_DIR or ~/.martin/runs."
93
108
  }
94
- },
95
- required: ["file"]
109
+ }
96
110
  }
97
111
  },
98
112
  {
99
113
  name: "martin_status",
100
- description: "Return the current budget and cost state of a Martin loop record. Useful for monitoring in-progress or completed loops.",
114
+ description: "Return the current budget and cost state of a Martin loop record. Accepts inline JSON, a saved loop file, a loopId under the run store, or the latest run in the store.",
101
115
  inputSchema: {
102
116
  type: "object",
103
117
  properties: {
104
118
  loopJson: {
105
119
  type: "string",
106
120
  description: "JSON-serialized LoopRecord."
121
+ },
122
+ file: {
123
+ type: "string",
124
+ description: "Optional path under the Martin runs root to a loop-record.json file, a legacy .jsonl file, or a run-store directory."
125
+ },
126
+ loopId: {
127
+ type: "string",
128
+ description: "Optional Martin loop ID. Loads <runsDir>/<loopId>/loop-record.json."
129
+ },
130
+ runsDir: {
131
+ type: "string",
132
+ description: "Optional Martin runs directory. Defaults to MARTIN_RUNS_DIR or ~/.martin/runs."
133
+ },
134
+ latest: {
135
+ type: "boolean",
136
+ description: "When true, loads the most recently updated loop record in the runs directory."
107
137
  }
108
- },
109
- required: ["loopJson"]
138
+ }
110
139
  }
111
140
  }
112
141
  ]
@@ -118,18 +147,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
118
147
  const { name, arguments: args } = request.params;
119
148
  try {
120
149
  if (name === "martin_run") {
121
- const input = args;
150
+ const input = validateToolInput("martin_run", args);
122
151
  const output = await runLoopTool(input);
123
152
  return { content: [{ type: "text", text: JSON.stringify(output, null, 2) }] };
124
153
  }
125
154
  if (name === "martin_inspect") {
126
- const input = args;
155
+ const input = validateToolInput("martin_inspect", args);
127
156
  const output = await inspectLoopTool(input);
128
157
  return { content: [{ type: "text", text: JSON.stringify(output, null, 2) }] };
129
158
  }
130
159
  if (name === "martin_status") {
131
- const input = args;
132
- const output = getStatusTool(input);
160
+ const input = validateToolInput("martin_status", args);
161
+ const output = await getStatusTool(input);
133
162
  return { content: [{ type: "text", text: JSON.stringify(output, null, 2) }] };
134
163
  }
135
164
  return {
@@ -138,7 +167,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
138
167
  };
139
168
  }
140
169
  catch (error) {
141
- const message = error instanceof Error ? error.message : String(error);
170
+ const message = sanitizeToolErrorMessage(error);
142
171
  return {
143
172
  content: [{ type: "text", text: `Tool error: ${message}` }],
144
173
  isError: true
@@ -1,6 +1,14 @@
1
1
  export interface GetStatusInput {
2
2
  /** JSON-serialized LoopRecord. */
3
- loopJson: string;
3
+ loopJson?: string;
4
+ /** Optional path to a JSON, JSONL, or run-store directory under the Martin runs root. */
5
+ file?: string;
6
+ /** Optional loop identifier under the Martin runs root. */
7
+ loopId?: string;
8
+ /** Optional Martin runs directory. Defaults to MARTIN_RUNS_DIR or ~/.martin/runs. */
9
+ runsDir?: string;
10
+ /** Load the newest loop record from the runs directory. */
11
+ latest?: boolean;
4
12
  }
5
13
  export interface GetStatusOutput {
6
14
  loopId: string;
@@ -15,4 +23,4 @@ export interface GetStatusOutput {
15
23
  remainingIterations: number;
16
24
  remainingTokens: number;
17
25
  }
18
- export declare function getStatusTool(input: GetStatusInput): GetStatusOutput;
26
+ export declare function getStatusTool(input: GetStatusInput): Promise<GetStatusOutput>;
@@ -1,9 +1,16 @@
1
1
  import { evaluateCostGovernor } from "../vendor/core/index.js";
2
- export function getStatusTool(input) {
3
- const loop = JSON.parse(input.loopJson);
2
+ import { loadLoopRecordForStatus } from "./run-store.js";
3
+ export async function getStatusTool(input) {
4
+ const resolved = await loadLoopRecordForStatus(input);
5
+ const loop = resolved.loop;
4
6
  const costState = evaluateCostGovernor({
5
7
  budget: loop.budget,
6
- cost: loop.cost,
8
+ cost: {
9
+ actualUsd: loop.cost.actualUsd,
10
+ avoidedUsd: loop.cost.avoidedUsd ?? 0,
11
+ tokensIn: loop.cost.tokensIn,
12
+ tokensOut: loop.cost.tokensOut
13
+ },
7
14
  attemptsUsed: loop.attempts.length
8
15
  });
9
16
  return {
@@ -12,7 +19,7 @@ export function getStatusTool(input) {
12
19
  lifecycleState: loop.lifecycleState,
13
20
  attempts: loop.attempts.length,
14
21
  costUsd: loop.cost.actualUsd,
15
- avoidedUsd: loop.cost.avoidedUsd,
22
+ avoidedUsd: loop.cost.avoidedUsd ?? 0,
16
23
  pressure: costState.pressure,
17
24
  shouldStop: costState.shouldStop,
18
25
  remainingBudgetUsd: costState.remainingBudgetUsd,
@@ -1,7 +1,9 @@
1
1
  import { type PortfolioSnapshot } from "../vendor/contracts/index.js";
2
2
  export interface InspectLoopInput {
3
- /** Absolute or relative path to a JSON file containing a LoopRecord or LoopRecord[]. */
4
- file: string;
3
+ /** Optional path to a JSON, JSONL, or run-store directory under the Martin runs root. */
4
+ file?: string;
5
+ /** Optional Martin runs directory. Defaults to MARTIN_RUNS_DIR or ~/.martin/runs. */
6
+ runsDir?: string;
5
7
  }
6
8
  export interface InspectLoopOutput {
7
9
  source: string;
@@ -1,13 +1,10 @@
1
- import { readFile } from "node:fs/promises";
2
1
  import { buildPortfolioSnapshot } from "../vendor/contracts/index.js";
2
+ import { loadLoopRecordsForInspect } from "./run-store.js";
3
3
  export async function inspectLoopTool(input) {
4
- const raw = await readFile(input.file, "utf8");
5
- const parsed = JSON.parse(raw);
6
- const loops = Array.isArray(parsed)
7
- ? parsed
8
- : [parsed];
4
+ const inspection = await loadLoopRecordsForInspect(input);
5
+ const loops = inspection.loops;
9
6
  return {
10
- source: input.file,
7
+ source: inspection.source,
11
8
  loopCount: loops.length,
12
9
  portfolio: buildPortfolioSnapshot(loops)
13
10
  };
@@ -7,6 +7,8 @@ export interface RunLoopInput {
7
7
  maxIterations?: number;
8
8
  maxTokens?: number;
9
9
  verificationPlan?: string[];
10
+ allowedPaths?: string[];
11
+ deniedPaths?: string[];
10
12
  workspaceId?: string;
11
13
  projectId?: string;
12
14
  }
@@ -1,10 +1,13 @@
1
1
  import { createClaudeCliAdapter, createCodexCliAdapter, createStubDirectProviderAdapter } from "../vendor/adapters/index.js";
2
- import { runMartin } from "../vendor/core/index.js";
2
+ import { createFileRunStore, resolveRunsRoot, runMartin } from "../vendor/core/index.js";
3
3
  import { DEFAULT_BUDGET } from "../vendor/contracts/index.js";
4
+ import { normalizeSafePathPatterns, resolveSafeRepoRoot } from "../server-validation.js";
4
5
  export async function runLoopTool(input) {
5
- const workingDirectory = input.workingDirectory ?? process.cwd();
6
+ const workingDirectory = resolveSafeRepoRoot(input.workingDirectory);
6
7
  const engine = input.engine ?? "claude";
7
8
  const model = input.model;
9
+ const allowedPaths = normalizeSafePathPatterns(input.allowedPaths, "allowedPaths");
10
+ const deniedPaths = normalizeSafePathPatterns(input.deniedPaths, "deniedPaths");
8
11
  const adapter = process.env.MARTIN_LIVE === "false"
9
12
  ? createStubDirectProviderAdapter({ label: "Stub adapter (MARTIN_LIVE=false)", providerId: "stub", model: "stub" })
10
13
  : engine === "codex"
@@ -27,10 +30,14 @@ export async function runLoopTool(input) {
27
30
  const result = await runMartin({
28
31
  workspaceId: input.workspaceId ?? "ws_mcp",
29
32
  projectId: input.projectId ?? "proj_mcp",
33
+ store: createFileRunStore({ runsRoot: resolveRunsRoot(process.env) }),
30
34
  task: {
31
35
  title: input.objective.slice(0, 100),
32
36
  objective: input.objective,
33
- verificationPlan: input.verificationPlan ?? []
37
+ verificationPlan: input.verificationPlan ?? [],
38
+ repoRoot: workingDirectory,
39
+ ...(allowedPaths ? { allowedPaths } : {}),
40
+ ...(deniedPaths ? { deniedPaths } : {})
34
41
  },
35
42
  budget,
36
43
  adapter