@martinloop/mcp 0.1.1 → 0.1.3

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 (36) hide show
  1. package/README.md +181 -41
  2. package/dist/server-validation.d.ts +10 -0
  3. package/dist/server-validation.js +234 -0
  4. package/dist/server.js +59 -15
  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 +17 -12
  36. package/server.json +21 -0
package/README.md CHANGED
@@ -1,59 +1,199 @@
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 that need hard spend limits, verifier gates, scoped file edits, and inspectable run records.
4
+
5
+ `@martinloop/mcp` exposes three stdio tools:
6
+
7
+ - `martin_run`
8
+ - `martin_inspect`
9
+ - `martin_status`
10
+
11
+ ## What This Server Is For
12
+
13
+ Use this MCP when a host already knows how to delegate coding work, but you want Martin Loop to bound that work with:
14
+
15
+ - a hard budget ceiling (`maxUsd`)
16
+ - an attempt ceiling (`maxIterations`)
17
+ - a total token ceiling (`maxTokens`)
18
+ - verifier commands (`verificationPlan`)
19
+ - allowed and denied file globs
20
+ - persisted run records you can inspect afterward
21
+
22
+ It is a good fit for Claude Code, Codex-oriented hosts, and other MCP clients that want governed code-change execution instead of open-ended retry behavior.
23
+
24
+ For host-facing integration guidance, see [MCP for AI Agents](https://github.com/Keesan12/martin-loop/blob/main/docs/oss/MCP-FOR-AI-AGENTS.md).
25
+
26
+ ## Quickstart
27
+
13
28
  Run the packaged server directly:
14
29
 
15
30
  ```sh
16
- npx @martinloop/mcp
31
+ npx -y @martinloop/mcp
17
32
  ```
18
33
 
19
34
  Add it to Claude Code:
20
35
 
21
36
  ```sh
22
37
  # macOS/Linux
23
- claude mcp add --scope user martin-loop -- npx @martinloop/mcp
38
+ claude mcp add --scope user martin-loop -- npx -y @martinloop/mcp
24
39
 
25
40
  # Windows PowerShell/cmd
26
- claude mcp add --scope user martin-loop cmd /c "npx @martinloop/mcp"
41
+ claude mcp add --scope user martin-loop cmd /c "npx -y @martinloop/mcp"
42
+ ```
43
+
44
+ Generic stdio configuration:
45
+
46
+ ```json
47
+ {
48
+ "type": "stdio",
49
+ "command": "npx",
50
+ "args": ["-y", "@martinloop/mcp"]
51
+ }
52
+ ```
53
+
54
+ Codex host configuration in `~/.codex/config.toml`:
55
+
56
+ ```toml
57
+ [mcp_servers.martin-loop]
58
+ command = "npx"
59
+ args = ["-y", "@martinloop/mcp"]
60
+ ```
61
+
62
+ ## Requirements
63
+
64
+ - Node 20+
65
+ - For live `martin_run` usage, either the `claude` CLI or the `codex` CLI must be available on `PATH`
66
+ - For stub or smoke flows, set `MARTIN_LIVE=false`
67
+
68
+ Example stub launch:
69
+
70
+ ```sh
71
+ MARTIN_LIVE=false npx -y @martinloop/mcp
72
+ ```
73
+
74
+ ## Tool Contract
75
+
76
+ | Tool | Purpose | Required input | Important optional input | Notes |
77
+ | --- | --- | --- | --- | --- |
78
+ | `martin_run` | Run a governed coding loop | `objective` | `workingDirectory`, `engine`, `model`, `maxUsd`, `maxIterations`, `maxTokens`, `verificationPlan`, `allowedPaths`, `deniedPaths`, `workspaceId`, `projectId` | Unknown arguments are rejected. |
79
+ | `martin_inspect` | Read a saved run record or run folder | none | `file`, `runsDir` | `file` may point to a `loop-record.json`, legacy `.jsonl`, or a run directory under the runs root. |
80
+ | `martin_status` | Report budget pressure and stop conditions | exactly one of `loopJson`, `file`, `loopId`, or `latest` | `runsDir` | `latest` must be `true` when used. |
81
+
82
+ ## Safe-Root Path Model
83
+
84
+ This MCP does not let tool callers point at arbitrary filesystem locations. The server resolves tool paths against safe roots chosen when the server starts.
85
+
86
+ - `workingDirectory`
87
+ Defaults to `MARTIN_MCP_WORKSPACE_ROOT` or the server process current directory. If you pass a value, it must still resolve inside that workspace root. `.` and repo-relative subpaths are the safest choices.
88
+ - `file`
89
+ For `martin_inspect` and `martin_status`, `file` resolves under the runs root, not the whole machine. Direct file targets must end in `.json` or `.jsonl`; run directories are also accepted where the tool supports them.
90
+ - `runsDir`
91
+ Defaults to `MARTIN_RUNS_DIR` or `~/.martin/runs`. Passing `runsDir` only re-states or narrows that safe runs root; it does not grant access outside it.
92
+ - `allowedPaths` and `deniedPaths`
93
+ These are relative glob patterns only. Absolute paths, drive-qualified paths, and patterns containing `..` are rejected.
94
+
95
+ Absolute paths can work only when they still resolve inside the corresponding safe root. Escapes above the workspace or runs root are rejected.
96
+
97
+ ## Tool Examples
98
+
99
+ ### `martin_run`
100
+
101
+ ```json
102
+ {
103
+ "objective": "Fix the auth regression and prove it with tests",
104
+ "engine": "codex",
105
+ "maxUsd": 3,
106
+ "maxIterations": 3,
107
+ "maxTokens": 20000,
108
+ "verificationPlan": ["pnpm test --filter auth"],
109
+ "workingDirectory": ".",
110
+ "allowedPaths": ["src/**", "tests/**"],
111
+ "deniedPaths": [".env*", "secrets/**"]
112
+ }
113
+ ```
114
+
115
+ ### `martin_inspect`
116
+
117
+ Inspect the default runs root:
118
+
119
+ ```json
120
+ {}
121
+ ```
122
+
123
+ Inspect a specific saved loop record under the runs root:
124
+
125
+ ```json
126
+ {
127
+ "file": "loop-123/loop-record.json"
128
+ }
129
+ ```
130
+
131
+ Inspect a subdirectory under the configured runs root:
132
+
133
+ ```json
134
+ {
135
+ "runsDir": "team-a"
136
+ }
137
+ ```
138
+
139
+ ### `martin_status`
140
+
141
+ Status for the latest saved run:
142
+
143
+ ```json
144
+ {
145
+ "latest": true
146
+ }
147
+ ```
148
+
149
+ Status for a specific persisted loop:
150
+
151
+ ```json
152
+ {
153
+ "loopId": "loop-123"
154
+ }
155
+ ```
156
+
157
+ Status from inline JSON:
158
+
159
+ ```json
160
+ {
161
+ "loopJson": "{\"loopId\":\"loop-123\",\"status\":\"completed\",\"lifecycleState\":\"completed\",\"attempts\":[],\"budget\":{\"maxUsd\":5,\"softLimitUsd\":3,\"maxIterations\":2,\"maxTokens\":1000},\"cost\":{\"actualUsd\":1.25,\"avoidedUsd\":0,\"tokensIn\":20,\"tokensOut\":10}}"
162
+ }
27
163
  ```
28
164
 
29
- For clients that want explicit command/args:
165
+ ## Registry Metadata
166
+
167
+ The registry manifest artifact for this package is `server.json`. In this repository, that manifest is authored at `packages/mcp/server.json`.
168
+
169
+ Current metadata:
30
170
 
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
171
  - 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
172
+ - registry server name: `io.github.keesan12/martin-loop`
173
+ - manifest artifact name: `server.json`
174
+
175
+ Official MCP Registry publication is separate from npm publication. After publishing the package to npm, run the publisher from `packages/mcp`:
176
+
177
+ ```sh
178
+ mcp-publisher login github
179
+ mcp-publisher publish
180
+ ```
181
+
182
+ ## Verification
183
+
184
+ From the repository root:
185
+
186
+ ```sh
187
+ pnpm --filter @martinloop/mcp lint
55
188
  pnpm --filter @martinloop/mcp test
189
+ pnpm --filter @martinloop/mcp build
56
190
  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.
191
+ pnpm --filter @martinloop/mcp smoke:published
192
+ ```
193
+
194
+ - `smoke:pack` verifies the packed tarball shape and a stdio MCP launch
195
+ - `smoke:published` verifies the npm-installed artifact through `npm install` plus live MCP tool calls
196
+
197
+ ## Version Notes
198
+
199
+ The root `CHANGELOG.md` is repo-wide and includes non-MCP changes. For the `@martinloop/mcp` surface, prefer this README, `server.json`, and the MCP release notes under `docs/release/`.
@@ -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
@@ -17,13 +17,17 @@
17
17
  * Manual start:
18
18
  * node dist/server.js
19
19
  */
20
+ import { createRequire } from "node:module";
20
21
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
21
22
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
22
23
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
23
24
  import { getStatusTool } from "./tools/get-status.js";
24
25
  import { inspectLoopTool } from "./tools/inspect-loop.js";
25
26
  import { runLoopTool } from "./tools/run-loop.js";
26
- const server = new Server({ name: "martin-loop", version: "0.1.1" }, { capabilities: { tools: {} } });
27
+ import { sanitizeToolErrorMessage, validateToolInput } from "./server-validation.js";
28
+ const require = createRequire(import.meta.url);
29
+ const packageJson = require("../package.json");
30
+ const server = new Server({ name: "martin-loop", version: packageJson.version }, { capabilities: { tools: {} } });
27
31
  // ---------------------------------------------------------------------------
28
32
  // Tool manifest
29
33
  // ---------------------------------------------------------------------------
@@ -34,6 +38,7 @@ server.setRequestHandler(ListToolsRequestSchema, () => ({
34
38
  description: "Execute a full Martin Loop on a coding task. Martin spawns the selected agent CLI (claude or codex), runs the task, classifies failures, and retries within the specified budget. Returns the loop outcome including lifecycle state, attempt count, and spend.",
35
39
  inputSchema: {
36
40
  type: "object",
41
+ additionalProperties: false,
37
42
  properties: {
38
43
  objective: {
39
44
  type: "string",
@@ -41,7 +46,7 @@ server.setRequestHandler(ListToolsRequestSchema, () => ({
41
46
  },
42
47
  workingDirectory: {
43
48
  type: "string",
44
- description: "Absolute path to the project root. Defaults to the current working directory."
49
+ description: "Optional repo-root override resolved under the MCP workspace root (or current working directory). Must stay within that safe root."
45
50
  },
46
51
  engine: {
47
52
  type: "string",
@@ -54,14 +59,17 @@ server.setRequestHandler(ListToolsRequestSchema, () => ({
54
59
  },
55
60
  maxUsd: {
56
61
  type: "number",
62
+ exclusiveMinimum: 0,
57
63
  description: "Hard budget ceiling in USD. Defaults to 25."
58
64
  },
59
65
  maxIterations: {
60
- type: "number",
66
+ type: "integer",
67
+ exclusiveMinimum: 0,
61
68
  description: "Maximum number of loop attempts. Defaults to 8."
62
69
  },
63
70
  maxTokens: {
64
- type: "number",
71
+ type: "integer",
72
+ exclusiveMinimum: 0,
65
73
  description: "Maximum total tokens across all attempts. Defaults to 80000."
66
74
  },
67
75
  verificationPlan: {
@@ -69,6 +77,16 @@ server.setRequestHandler(ListToolsRequestSchema, () => ({
69
77
  items: { type: "string" },
70
78
  description: "Shell commands that must all exit 0 for the task to be considered complete (e.g. ['pnpm test', 'pnpm build'])."
71
79
  },
80
+ allowedPaths: {
81
+ type: "array",
82
+ items: { type: "string" },
83
+ description: "Repo-relative path globs Martin may modify, such as ['src/**', 'tests/**']. Absolute paths and '..' traversal are rejected."
84
+ },
85
+ deniedPaths: {
86
+ type: "array",
87
+ items: { type: "string" },
88
+ description: "Repo-relative path globs Martin must never modify, such as ['.env', 'docs/security/**']. Absolute paths and '..' traversal are rejected."
89
+ },
72
90
  workspaceId: {
73
91
  type: "string",
74
92
  description: "Workspace identifier for telemetry. Defaults to 'ws_mcp'."
@@ -83,30 +101,56 @@ server.setRequestHandler(ListToolsRequestSchema, () => ({
83
101
  },
84
102
  {
85
103
  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.",
104
+ 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
105
  inputSchema: {
88
106
  type: "object",
107
+ additionalProperties: false,
89
108
  properties: {
90
109
  file: {
91
110
  type: "string",
92
- description: "Absolute or relative path to a LoopRecord JSON file."
111
+ description: "Optional path resolved under the Martin runs root to a loop-record.json file, a legacy .jsonl file, or a run-store directory."
112
+ },
113
+ runsDir: {
114
+ type: "string",
115
+ description: "Optional runs-root override resolved under the default Martin runs root. Defaults to MARTIN_RUNS_DIR or ~/.martin/runs."
93
116
  }
94
- },
95
- required: ["file"]
117
+ }
96
118
  }
97
119
  },
98
120
  {
99
121
  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.",
122
+ 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
123
  inputSchema: {
102
124
  type: "object",
125
+ additionalProperties: false,
103
126
  properties: {
104
127
  loopJson: {
105
128
  type: "string",
106
129
  description: "JSON-serialized LoopRecord."
130
+ },
131
+ file: {
132
+ type: "string",
133
+ description: "Optional path resolved under the Martin runs root to a loop-record.json file, a legacy .jsonl file, or a run-store directory."
134
+ },
135
+ loopId: {
136
+ type: "string",
137
+ description: "Optional Martin loop ID. Loads <runsDir>/<loopId>/loop-record.json."
138
+ },
139
+ runsDir: {
140
+ type: "string",
141
+ description: "Optional runs-root override resolved under the default Martin runs root. Defaults to MARTIN_RUNS_DIR or ~/.martin/runs."
142
+ },
143
+ latest: {
144
+ const: true,
145
+ description: "When true, loads the most recently updated loop record in the runs directory."
107
146
  }
108
147
  },
109
- required: ["loopJson"]
148
+ oneOf: [
149
+ { required: ["loopJson"] },
150
+ { required: ["file"] },
151
+ { required: ["loopId"] },
152
+ { required: ["latest"] }
153
+ ]
110
154
  }
111
155
  }
112
156
  ]
@@ -118,18 +162,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
118
162
  const { name, arguments: args } = request.params;
119
163
  try {
120
164
  if (name === "martin_run") {
121
- const input = args;
165
+ const input = validateToolInput("martin_run", args);
122
166
  const output = await runLoopTool(input);
123
167
  return { content: [{ type: "text", text: JSON.stringify(output, null, 2) }] };
124
168
  }
125
169
  if (name === "martin_inspect") {
126
- const input = args;
170
+ const input = validateToolInput("martin_inspect", args);
127
171
  const output = await inspectLoopTool(input);
128
172
  return { content: [{ type: "text", text: JSON.stringify(output, null, 2) }] };
129
173
  }
130
174
  if (name === "martin_status") {
131
- const input = args;
132
- const output = getStatusTool(input);
175
+ const input = validateToolInput("martin_status", args);
176
+ const output = await getStatusTool(input);
133
177
  return { content: [{ type: "text", text: JSON.stringify(output, null, 2) }] };
134
178
  }
135
179
  return {
@@ -138,7 +182,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
138
182
  };
139
183
  }
140
184
  catch (error) {
141
- const message = error instanceof Error ? error.message : String(error);
185
+ const message = sanitizeToolErrorMessage(error);
142
186
  return {
143
187
  content: [{ type: "text", text: `Tool error: ${message}` }],
144
188
  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>;