@martinloop/mcp 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,81 +1,127 @@
1
1
  # @martinloop/mcp
2
2
 
3
- Governed MCP server for AI coding agents with hard budgets, verifier gates, policy checks, and inspectable run records.
3
+ Governed MCP server for AI coding agents that need hard spend limits, verifier gates, scoped file edits, and inspectable run records.
4
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:
5
+ `@martinloop/mcp@0.1.4` exposes five stdio tools:
6
6
 
7
+ - `martin_doctor`
8
+ - `martin_preflight`
7
9
  - `martin_run`
8
10
  - `martin_inspect`
9
11
  - `martin_status`
10
12
 
11
- ## What's new in 0.1.2
13
+ Recommended flow:
12
14
 
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
15
+ 1. `martin_doctor`
16
+ 2. `martin_preflight`
17
+ 3. `martin_run`
18
+ 4. `martin_inspect` or `martin_status`
19
+
20
+ ## What This Server Is For
21
+
22
+ Use this MCP when a host already knows how to delegate coding work, but you want Martin Loop to bound that work with:
23
+
24
+ - a hard budget ceiling (`maxUsd`)
25
+ - an attempt ceiling (`maxIterations`)
26
+ - a total token ceiling (`maxTokens`)
27
+ - verifier commands (`verificationPlan`)
28
+ - allowed and denied file globs
29
+ - persisted run records you can inspect afterward
30
+
31
+ 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.
32
+
33
+ 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).
19
34
 
20
35
  ## Quickstart
21
36
 
22
37
  Run the packaged server directly:
23
38
 
24
39
  ```sh
25
- npx @martinloop/mcp
40
+ npx -y @martinloop/mcp
41
+ ```
42
+
43
+ Add it to Codex:
44
+
45
+ ```sh
46
+ codex mcp add martin-loop -- npx -y @martinloop/mcp
26
47
  ```
27
48
 
28
49
  Add it to Claude Code:
29
50
 
30
51
  ```sh
31
52
  # macOS/Linux
32
- claude mcp add --scope user martin-loop -- npx @martinloop/mcp
53
+ claude mcp add --transport stdio --scope user martin-loop -- npx -y @martinloop/mcp
33
54
 
34
55
  # Windows PowerShell/cmd
35
- claude mcp add --scope user martin-loop cmd /c "npx @martinloop/mcp"
56
+ claude mcp add --transport stdio --scope user martin-loop -- cmd /c npx -y @martinloop/mcp
36
57
  ```
37
58
 
38
- Generic stdio configuration for non-Claude clients:
59
+ Generic stdio configuration:
39
60
 
40
61
  ```json
41
62
  {
42
63
  "type": "stdio",
43
64
  "command": "npx",
44
- "args": ["@martinloop/mcp"]
65
+ "args": ["-y", "@martinloop/mcp"]
45
66
  }
46
67
  ```
47
68
 
48
- ## What the tools do
69
+ Codex host configuration in `~/.codex/config.toml`:
49
70
 
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
71
+ ```toml
72
+ [mcp_servers.martin-loop]
73
+ command = "npx"
74
+ args = ["-y", "@martinloop/mcp"]
75
+ ```
53
76
 
54
77
  ## Requirements
55
78
 
56
79
  - 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`
80
+ - For live `martin_run` usage, either the `claude` CLI or the `codex` CLI must be available on `PATH`
81
+ - For stub or smoke flows, set `MARTIN_LIVE=false`
59
82
 
60
- Live `martin_run` delegates to the configured CLI adapter. If no supported CLI is installed, use the stub path for testing:
83
+ Example stub launch:
61
84
 
62
85
  ```sh
63
- MARTIN_LIVE=false npx @martinloop/mcp
86
+ MARTIN_LIVE=false npx -y @martinloop/mcp
64
87
  ```
65
88
 
66
- ## Tool examples
89
+ ## Tool Contract
67
90
 
68
- ### `martin_run`
91
+ | Tool | Purpose | Required input | Important optional input | Notes |
92
+ | --- | --- | --- | --- | --- |
93
+ | `martin_doctor` | Inspect local readiness and run-store health | none | `workingDirectory`, `runsDir`, `engine` | Read-only setup lane before execution. |
94
+ | `martin_preflight` | Normalize and validate a proposed run contract | `objective` | `workingDirectory`, `engine`, `model`, `maxUsd`, `maxIterations`, `maxTokens`, `verificationPlan`, `allowedPaths`, `deniedPaths`, `workspaceId`, `projectId` | Read-only contract check; does not execute work. |
95
+ | `martin_run` | Run a governed coding loop | `objective` | `workingDirectory`, `engine`, `model`, `maxUsd`, `maxIterations`, `maxTokens`, `verificationPlan`, `allowedPaths`, `deniedPaths`, `workspaceId`, `projectId` | Unknown arguments are rejected. |
96
+ | `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. |
97
+ | `martin_status` | Report budget pressure and stop conditions | exactly one of `loopJson`, `file`, `loopId`, or `latest` | `runsDir` | `latest` must be `true` when used. |
98
+
99
+ ## Safe-Root Path Model
100
+
101
+ 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.
69
102
 
70
- Example request body:
103
+ - `workingDirectory`
104
+ 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.
105
+ - `file`
106
+ 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.
107
+ - `runsDir`
108
+ 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.
109
+ - `allowedPaths` and `deniedPaths`
110
+ These are relative glob patterns only. Absolute paths, drive-qualified paths, and patterns containing `..` are rejected.
111
+
112
+ Absolute paths can work only when they still resolve inside the corresponding safe root. Escapes above the workspace or runs root are rejected.
113
+
114
+ ## Tool Examples
115
+
116
+ ### `martin_run`
71
117
 
72
118
  ```json
73
119
  {
74
120
  "objective": "Fix the auth regression and prove it with tests",
75
121
  "engine": "codex",
76
- "budgetUsd": 3,
77
- "softLimitUsd": 2.25,
122
+ "maxUsd": 3,
78
123
  "maxIterations": 3,
124
+ "maxTokens": 20000,
79
125
  "verificationPlan": ["pnpm test --filter auth"],
80
126
  "workingDirectory": ".",
81
127
  "allowedPaths": ["src/**", "tests/**"],
@@ -85,25 +131,25 @@ Example request body:
85
131
 
86
132
  ### `martin_inspect`
87
133
 
88
- Inspect the default run store:
134
+ Inspect the default runs root:
89
135
 
90
136
  ```json
91
137
  {}
92
138
  ```
93
139
 
94
- Inspect a legacy JSONL file directly:
140
+ Inspect a specific saved loop record under the runs root:
95
141
 
96
142
  ```json
97
143
  {
98
- "file": "C:/Users/you/.martin/runs/workspace.jsonl"
144
+ "file": "loop-123/loop-record.json"
99
145
  }
100
146
  ```
101
147
 
102
- Inspect a canonical runs directory:
148
+ Inspect a subdirectory under the configured runs root:
103
149
 
104
150
  ```json
105
151
  {
106
- "runsDir": "C:/Users/you/.martin/runs"
152
+ "runsDir": "team-a"
107
153
  }
108
154
  ```
109
155
 
@@ -121,27 +167,36 @@ Status for a specific persisted loop:
121
167
 
122
168
  ```json
123
169
  {
124
- "loopId": "loop-123",
125
- "runsDir": "C:/Users/you/.martin/runs"
170
+ "loopId": "loop-123"
126
171
  }
127
172
  ```
128
173
 
129
- ## Official MCP Registry
174
+ Status from inline JSON:
130
175
 
131
- This package is prepared for the official MCP Registry metadata flow:
176
+ ```json
177
+ {
178
+ "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}}"
179
+ }
180
+ ```
181
+
182
+ ## Registry Metadata
183
+
184
+ The registry manifest artifact for this package is `server.json`. In this repository, that manifest is authored at `packages/mcp/server.json`.
185
+
186
+ Current metadata:
132
187
 
133
188
  - npm package: `@martinloop/mcp`
134
- - registry server name: `io.github.keesan12/martin-loop`
135
- - manifest file: `packages/mcp/server.json`
189
+ - registry server name: `io.github.Keesan12/martin-loop`
190
+ - manifest artifact name: `server.json`
136
191
 
137
- The official registry publish flow is separate from npm publication. After publishing the package to npm, run the publisher from `packages/mcp`:
192
+ Official MCP Registry publication is separate from npm publication. After publishing the package to npm, run the publisher from `packages/mcp`:
138
193
 
139
194
  ```sh
140
195
  mcp-publisher login github
141
196
  mcp-publisher publish
142
197
  ```
143
198
 
144
- ## Local verification
199
+ ## Verification
145
200
 
146
201
  From the repository root:
147
202
 
@@ -150,8 +205,16 @@ pnpm --filter @martinloop/mcp lint
150
205
  pnpm --filter @martinloop/mcp test
151
206
  pnpm --filter @martinloop/mcp build
152
207
  pnpm --filter @martinloop/mcp smoke:pack
208
+ pnpm --filter @martinloop/mcp smoke:published:pack
209
+ pnpm --filter @martinloop/mcp verify:release
153
210
  pnpm --filter @martinloop/mcp smoke:published
154
211
  ```
155
212
 
156
- - `smoke:pack` validates the local packed tarball before publish
157
- - `smoke:published` validates the npm-published artifact through `npm exec`
213
+ - `smoke:pack` verifies the packed tarball shape and a stdio MCP launch
214
+ - `smoke:published:pack` verifies install-and-run behavior from a freshly packed local tarball before npm publish
215
+ - `verify:release` checks metadata parity, release-note presence, and public MCP doc accuracy for the current package version
216
+ - `smoke:published` verifies the npm-installed artifact through `npm install` plus live MCP tool calls
217
+
218
+ ## Version Notes
219
+
220
+ The root `CHANGELOG.md` is repo-wide and includes non-MCP changes. For the `@martinloop/mcp` surface, prefer this README and `server.json`.
@@ -1,4 +1,4 @@
1
- type ToolName = "martin_run" | "martin_inspect" | "martin_status";
1
+ type ToolName = "martin_doctor" | "martin_preflight" | "martin_run" | "martin_inspect" | "martin_status";
2
2
  export declare function validateToolInput(name: ToolName, args: unknown): unknown;
3
3
  export declare function sanitizeToolErrorMessage(error: unknown): string;
4
4
  export declare function resolveSafeRepoRoot(repoRoot?: string, workspaceRoot?: string): string;
@@ -2,6 +2,10 @@ import { extname, isAbsolute, relative, resolve } from "node:path";
2
2
  import { resolveRunsRoot } from "./vendor/core/index.js";
3
3
  export function validateToolInput(name, args) {
4
4
  switch (name) {
5
+ case "martin_doctor":
6
+ return validateDoctorInput(args);
7
+ case "martin_preflight":
8
+ return validatePreflightInput(args);
5
9
  case "martin_run":
6
10
  return validateRunInput(args);
7
11
  case "martin_inspect":
@@ -18,6 +22,54 @@ export function sanitizeToolErrorMessage(error) {
18
22
  ? "Tool execution failed."
19
23
  : message;
20
24
  }
25
+ function validateDoctorInput(args) {
26
+ const record = requireObject(args);
27
+ assertAllowedKeys(record, ["workingDirectory", "runsDir", "engine"]);
28
+ const engine = optionalEnum(record.engine, "engine", ["claude", "codex"]);
29
+ return {
30
+ ...(record.workingDirectory !== undefined
31
+ ? { workingDirectory: resolveSafeRepoRoot(requireString(record.workingDirectory, "workingDirectory")) }
32
+ : {}),
33
+ ...(record.runsDir !== undefined
34
+ ? { runsDir: resolveSafeRunsRootPath(requireString(record.runsDir, "runsDir")) }
35
+ : {}),
36
+ ...(engine ? { engine } : {})
37
+ };
38
+ }
39
+ function validatePreflightInput(args) {
40
+ const record = requireObject(args);
41
+ assertAllowedKeys(record, [
42
+ "objective",
43
+ "workingDirectory",
44
+ "engine",
45
+ "model",
46
+ "maxUsd",
47
+ "maxIterations",
48
+ "maxTokens",
49
+ "verificationPlan",
50
+ "allowedPaths",
51
+ "deniedPaths",
52
+ "workspaceId",
53
+ "projectId"
54
+ ]);
55
+ const engine = optionalEnum(record.engine, "engine", ["claude", "codex"]);
56
+ return {
57
+ objective: requireString(record.objective, "objective"),
58
+ ...(record.workingDirectory !== undefined
59
+ ? { workingDirectory: resolveSafeRepoRoot(requireString(record.workingDirectory, "workingDirectory")) }
60
+ : {}),
61
+ ...(engine ? { engine } : {}),
62
+ ...optionalString(record.model, "model"),
63
+ ...optionalPositiveNumber(record.maxUsd, "maxUsd"),
64
+ ...optionalPositiveInteger(record.maxIterations, "maxIterations"),
65
+ ...optionalPositiveInteger(record.maxTokens, "maxTokens"),
66
+ ...optionalStringArrayAsObject(record.verificationPlan, "verificationPlan"),
67
+ ...optionalPathPatternArrayAsObject(record.allowedPaths, "allowedPaths"),
68
+ ...optionalPathPatternArrayAsObject(record.deniedPaths, "deniedPaths"),
69
+ ...optionalString(record.workspaceId, "workspaceId"),
70
+ ...optionalString(record.projectId, "projectId")
71
+ };
72
+ }
21
73
  export function resolveSafeRepoRoot(repoRoot, workspaceRoot = process.env.MARTIN_MCP_WORKSPACE_ROOT ?? process.cwd()) {
22
74
  const baseRoot = resolve(workspaceRoot);
23
75
  const candidate = repoRoot ? resolve(baseRoot, repoRoot) : baseRoot;
package/dist/server.d.ts CHANGED
@@ -2,10 +2,12 @@
2
2
  /**
3
3
  * Martin Loop MCP Server
4
4
  *
5
- * Exposes three tools over the Model Context Protocol (stdio transport):
6
- * martin_run execute a full Martin loop on a coding task
7
- * martin_inspectsummarise a saved loop record file
8
- * martin_status return cost and pressure state from a loop record
5
+ * Exposes five tools over the Model Context Protocol (stdio transport):
6
+ * martin_doctor inspect local readiness and run-store health
7
+ * martin_preflightnormalize a proposed run contract before execution
8
+ * martin_run execute a full Martin loop on a coding task
9
+ * martin_inspect — summarise a saved loop record file
10
+ * martin_status — return cost and pressure state from a loop record
9
11
  *
10
12
  * Setup (Claude Code):
11
13
  * macOS/Linux: claude mcp add --scope user martin-loop -- npx @martinloop/mcp
package/dist/server.js CHANGED
@@ -2,10 +2,12 @@
2
2
  /**
3
3
  * Martin Loop MCP Server
4
4
  *
5
- * Exposes three tools over the Model Context Protocol (stdio transport):
6
- * martin_run execute a full Martin loop on a coding task
7
- * martin_inspectsummarise a saved loop record file
8
- * martin_status return cost and pressure state from a loop record
5
+ * Exposes five tools over the Model Context Protocol (stdio transport):
6
+ * martin_doctor inspect local readiness and run-store health
7
+ * martin_preflightnormalize a proposed run contract before execution
8
+ * martin_run execute a full Martin loop on a coding task
9
+ * martin_inspect — summarise a saved loop record file
10
+ * martin_status — return cost and pressure state from a loop record
9
11
  *
10
12
  * Setup (Claude Code):
11
13
  * macOS/Linux: claude mcp add --scope user martin-loop -- npx @martinloop/mcp
@@ -17,24 +19,119 @@
17
19
  * Manual start:
18
20
  * node dist/server.js
19
21
  */
22
+ import { createRequire } from "node:module";
20
23
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
21
24
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
22
25
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
26
+ import { martinDoctorTool } from "./tools/doctor.js";
23
27
  import { getStatusTool } from "./tools/get-status.js";
24
28
  import { inspectLoopTool } from "./tools/inspect-loop.js";
29
+ import { martinPreflightTool } from "./tools/preflight.js";
25
30
  import { runLoopTool } from "./tools/run-loop.js";
26
31
  import { sanitizeToolErrorMessage, validateToolInput } from "./server-validation.js";
27
- const server = new Server({ name: "martin-loop", version: "0.1.2" }, { capabilities: { tools: {} } });
32
+ const require = createRequire(import.meta.url);
33
+ const packageJson = require("../package.json");
34
+ const server = new Server({ name: "martin-loop", version: packageJson.version }, { capabilities: { tools: {} } });
28
35
  // ---------------------------------------------------------------------------
29
36
  // Tool manifest
30
37
  // ---------------------------------------------------------------------------
31
38
  server.setRequestHandler(ListToolsRequestSchema, () => ({
32
39
  tools: [
40
+ {
41
+ name: "martin_doctor",
42
+ description: "Inspect Martin MCP readiness without changing code. Reports workspace roots, run-store visibility, execution mode, and whether claude or codex is available on PATH.",
43
+ inputSchema: {
44
+ type: "object",
45
+ additionalProperties: false,
46
+ properties: {
47
+ workingDirectory: {
48
+ type: "string",
49
+ description: "Optional repo-root override resolved under the MCP workspace root (or current working directory). Must stay within that safe root."
50
+ },
51
+ runsDir: {
52
+ type: "string",
53
+ description: "Optional runs-root override resolved under the default Martin runs root. Defaults to MARTIN_RUNS_DIR or ~/.martin/runs."
54
+ },
55
+ engine: {
56
+ type: "string",
57
+ enum: ["claude", "codex"],
58
+ description: "Optional engine to emphasize in the readiness report."
59
+ }
60
+ }
61
+ }
62
+ },
63
+ {
64
+ name: "martin_preflight",
65
+ description: "Validate and normalize a proposed martin_run contract before execution. Reports the effective budget, path scope, engine readiness, and expected run-store layout.",
66
+ inputSchema: {
67
+ type: "object",
68
+ additionalProperties: false,
69
+ properties: {
70
+ objective: {
71
+ type: "string",
72
+ description: "The coding task to complete. Be specific about what needs to change."
73
+ },
74
+ workingDirectory: {
75
+ type: "string",
76
+ description: "Optional repo-root override resolved under the MCP workspace root (or current working directory). Must stay within that safe root."
77
+ },
78
+ engine: {
79
+ type: "string",
80
+ enum: ["claude", "codex"],
81
+ description: "Which agent CLI to use. Defaults to 'claude'."
82
+ },
83
+ model: {
84
+ type: "string",
85
+ description: "Model override passed to the CLI (e.g. 'claude-opus-4-6', 'o3')."
86
+ },
87
+ maxUsd: {
88
+ type: "number",
89
+ exclusiveMinimum: 0,
90
+ description: "Hard budget ceiling in USD. Defaults to 25."
91
+ },
92
+ maxIterations: {
93
+ type: "integer",
94
+ exclusiveMinimum: 0,
95
+ description: "Maximum number of loop attempts. Defaults to 8."
96
+ },
97
+ maxTokens: {
98
+ type: "integer",
99
+ exclusiveMinimum: 0,
100
+ description: "Maximum total tokens across all attempts. Defaults to 80000."
101
+ },
102
+ verificationPlan: {
103
+ type: "array",
104
+ items: { type: "string" },
105
+ description: "Shell commands that must all exit 0 for the task to be considered complete (e.g. ['pnpm test', 'pnpm build'])."
106
+ },
107
+ allowedPaths: {
108
+ type: "array",
109
+ items: { type: "string" },
110
+ description: "Repo-relative path globs Martin may modify, such as ['src/**', 'tests/**']. Absolute paths and '..' traversal are rejected."
111
+ },
112
+ deniedPaths: {
113
+ type: "array",
114
+ items: { type: "string" },
115
+ description: "Repo-relative path globs Martin must never modify, such as ['.env', 'docs/security/**']. Absolute paths and '..' traversal are rejected."
116
+ },
117
+ workspaceId: {
118
+ type: "string",
119
+ description: "Workspace identifier for telemetry. Defaults to 'ws_mcp'."
120
+ },
121
+ projectId: {
122
+ type: "string",
123
+ description: "Project identifier for telemetry. Defaults to 'proj_mcp'."
124
+ }
125
+ },
126
+ required: ["objective"]
127
+ }
128
+ },
33
129
  {
34
130
  name: "martin_run",
35
131
  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.",
36
132
  inputSchema: {
37
133
  type: "object",
134
+ additionalProperties: false,
38
135
  properties: {
39
136
  objective: {
40
137
  type: "string",
@@ -42,7 +139,7 @@ server.setRequestHandler(ListToolsRequestSchema, () => ({
42
139
  },
43
140
  workingDirectory: {
44
141
  type: "string",
45
- description: "Absolute path to the project root. Defaults to the current working directory."
142
+ description: "Optional repo-root override resolved under the MCP workspace root (or current working directory). Must stay within that safe root."
46
143
  },
47
144
  engine: {
48
145
  type: "string",
@@ -55,14 +152,17 @@ server.setRequestHandler(ListToolsRequestSchema, () => ({
55
152
  },
56
153
  maxUsd: {
57
154
  type: "number",
155
+ exclusiveMinimum: 0,
58
156
  description: "Hard budget ceiling in USD. Defaults to 25."
59
157
  },
60
158
  maxIterations: {
61
- type: "number",
159
+ type: "integer",
160
+ exclusiveMinimum: 0,
62
161
  description: "Maximum number of loop attempts. Defaults to 8."
63
162
  },
64
163
  maxTokens: {
65
- type: "number",
164
+ type: "integer",
165
+ exclusiveMinimum: 0,
66
166
  description: "Maximum total tokens across all attempts. Defaults to 80000."
67
167
  },
68
168
  verificationPlan: {
@@ -73,12 +173,12 @@ server.setRequestHandler(ListToolsRequestSchema, () => ({
73
173
  allowedPaths: {
74
174
  type: "array",
75
175
  items: { type: "string" },
76
- description: "Relative path globs Martin may modify, such as ['src/**', 'tests/**']."
176
+ description: "Repo-relative path globs Martin may modify, such as ['src/**', 'tests/**']. Absolute paths and '..' traversal are rejected."
77
177
  },
78
178
  deniedPaths: {
79
179
  type: "array",
80
180
  items: { type: "string" },
81
- description: "Relative path globs Martin must never modify, such as ['.env', 'docs/security/**']."
181
+ description: "Repo-relative path globs Martin must never modify, such as ['.env', 'docs/security/**']. Absolute paths and '..' traversal are rejected."
82
182
  },
83
183
  workspaceId: {
84
184
  type: "string",
@@ -97,14 +197,15 @@ server.setRequestHandler(ListToolsRequestSchema, () => ({
97
197
  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.",
98
198
  inputSchema: {
99
199
  type: "object",
200
+ additionalProperties: false,
100
201
  properties: {
101
202
  file: {
102
203
  type: "string",
103
- description: "Optional path under the Martin runs root to a loop-record.json file, a legacy .jsonl file, or a run-store directory."
204
+ description: "Optional path resolved under the Martin runs root to a loop-record.json file, a legacy .jsonl file, or a run-store directory."
104
205
  },
105
206
  runsDir: {
106
207
  type: "string",
107
- description: "Optional Martin runs directory. Defaults to MARTIN_RUNS_DIR or ~/.martin/runs."
208
+ description: "Optional runs-root override resolved under the default Martin runs root. Defaults to MARTIN_RUNS_DIR or ~/.martin/runs."
108
209
  }
109
210
  }
110
211
  }
@@ -114,6 +215,7 @@ server.setRequestHandler(ListToolsRequestSchema, () => ({
114
215
  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.",
115
216
  inputSchema: {
116
217
  type: "object",
218
+ additionalProperties: false,
117
219
  properties: {
118
220
  loopJson: {
119
221
  type: "string",
@@ -121,7 +223,7 @@ server.setRequestHandler(ListToolsRequestSchema, () => ({
121
223
  },
122
224
  file: {
123
225
  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."
226
+ description: "Optional path resolved under the Martin runs root to a loop-record.json file, a legacy .jsonl file, or a run-store directory."
125
227
  },
126
228
  loopId: {
127
229
  type: "string",
@@ -129,13 +231,19 @@ server.setRequestHandler(ListToolsRequestSchema, () => ({
129
231
  },
130
232
  runsDir: {
131
233
  type: "string",
132
- description: "Optional Martin runs directory. Defaults to MARTIN_RUNS_DIR or ~/.martin/runs."
234
+ description: "Optional runs-root override resolved under the default Martin runs root. Defaults to MARTIN_RUNS_DIR or ~/.martin/runs."
133
235
  },
134
236
  latest: {
135
- type: "boolean",
237
+ const: true,
136
238
  description: "When true, loads the most recently updated loop record in the runs directory."
137
239
  }
138
- }
240
+ },
241
+ oneOf: [
242
+ { required: ["loopJson"] },
243
+ { required: ["file"] },
244
+ { required: ["loopId"] },
245
+ { required: ["latest"] }
246
+ ]
139
247
  }
140
248
  }
141
249
  ]
@@ -146,6 +254,16 @@ server.setRequestHandler(ListToolsRequestSchema, () => ({
146
254
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
147
255
  const { name, arguments: args } = request.params;
148
256
  try {
257
+ if (name === "martin_doctor") {
258
+ const input = validateToolInput("martin_doctor", args);
259
+ const output = await martinDoctorTool(input);
260
+ return { content: [{ type: "text", text: JSON.stringify(output, null, 2) }] };
261
+ }
262
+ if (name === "martin_preflight") {
263
+ const input = validateToolInput("martin_preflight", args);
264
+ const output = await martinPreflightTool(input);
265
+ return { content: [{ type: "text", text: JSON.stringify(output, null, 2) }] };
266
+ }
149
267
  if (name === "martin_run") {
150
268
  const input = validateToolInput("martin_run", args);
151
269
  const output = await runLoopTool(input);
@@ -0,0 +1,35 @@
1
+ import { type LoopPreview, type MartinEngine } from "./tool-support.js";
2
+ export interface MartinDoctorInput {
3
+ workingDirectory?: string;
4
+ runsDir?: string;
5
+ engine?: MartinEngine;
6
+ }
7
+ export interface MartinDoctorOutput {
8
+ status: "ok" | "degraded";
9
+ summary: string;
10
+ server: {
11
+ name: "martin-loop";
12
+ nodeVersion: string;
13
+ platform: NodeJS.Platform;
14
+ };
15
+ environment: {
16
+ workspaceRoot: string;
17
+ workingDirectory: string;
18
+ runsRoot: string;
19
+ mode: "live" | "stub";
20
+ liveMode: boolean;
21
+ };
22
+ engines: Record<MartinEngine, {
23
+ available: boolean;
24
+ detail: string;
25
+ resolvedPath?: string;
26
+ }>;
27
+ requestedEngine?: MartinEngine;
28
+ runStore: {
29
+ exists: boolean;
30
+ loopCount: number;
31
+ latestRun?: LoopPreview;
32
+ };
33
+ warnings: string[];
34
+ }
35
+ export declare function martinDoctorTool(input: MartinDoctorInput): Promise<MartinDoctorOutput>;
@@ -0,0 +1,56 @@
1
+ import { resolveRunsRoot } from "../vendor/core/index.js";
2
+ import { resolveSafeRepoRoot, resolveSafeRunsRootPath } from "../server-validation.js";
3
+ import { getEngineAvailability, inspectRunsRoot, resolveExecutionMode } from "./tool-support.js";
4
+ export async function martinDoctorTool(input) {
5
+ const workingDirectory = resolveSafeRepoRoot(input.workingDirectory);
6
+ const runsRoot = resolveSafeRunsRootPath(input.runsDir, resolveRunsRoot(process.env));
7
+ const executionMode = resolveExecutionMode();
8
+ const claude = getEngineAvailability("claude");
9
+ const codex = getEngineAvailability("codex");
10
+ const runStore = await inspectRunsRoot(runsRoot);
11
+ const warnings = [];
12
+ if (!runStore.exists) {
13
+ warnings.push("Configured Martin runs root does not exist yet.");
14
+ }
15
+ if (executionMode.liveMode && !claude.available && !codex.available) {
16
+ warnings.push("Neither claude nor codex is currently available on PATH for live runs.");
17
+ }
18
+ if (input.engine && executionMode.liveMode) {
19
+ const selected = input.engine === "claude" ? claude : codex;
20
+ if (!selected.available) {
21
+ warnings.push(`Requested engine '${input.engine}' is not available on PATH.`);
22
+ }
23
+ }
24
+ warnings.push(...runStore.warnings);
25
+ const status = warnings.length === 0 ? "ok" : "degraded";
26
+ return {
27
+ status,
28
+ summary: status === "ok"
29
+ ? `Doctor passed: ${runStore.loopCount} run(s) visible in ${runsRoot}.`
30
+ : `Doctor found ${warnings.length} issue(s); review warnings before live execution.`,
31
+ server: {
32
+ name: "martin-loop",
33
+ nodeVersion: process.version,
34
+ platform: process.platform
35
+ },
36
+ environment: {
37
+ workspaceRoot: resolveSafeRepoRoot(),
38
+ workingDirectory,
39
+ runsRoot,
40
+ mode: executionMode.mode,
41
+ liveMode: executionMode.liveMode
42
+ },
43
+ engines: {
44
+ claude,
45
+ codex
46
+ },
47
+ ...(input.engine ? { requestedEngine: input.engine } : {}),
48
+ runStore: {
49
+ exists: runStore.exists,
50
+ loopCount: runStore.loopCount,
51
+ ...(runStore.latestRun ? { latestRun: runStore.latestRun } : {})
52
+ },
53
+ warnings
54
+ };
55
+ }
56
+ //# sourceMappingURL=doctor.js.map
@@ -0,0 +1,62 @@
1
+ import { type MartinEngine } from "./tool-support.js";
2
+ export interface MartinPreflightInput {
3
+ objective: string;
4
+ workingDirectory?: string;
5
+ engine?: MartinEngine;
6
+ model?: string;
7
+ maxUsd?: number;
8
+ maxIterations?: number;
9
+ maxTokens?: number;
10
+ verificationPlan?: string[];
11
+ allowedPaths?: string[];
12
+ deniedPaths?: string[];
13
+ workspaceId?: string;
14
+ projectId?: string;
15
+ }
16
+ export interface MartinPreflightOutput {
17
+ ok: boolean;
18
+ summary: string;
19
+ warnings: string[];
20
+ readiness: {
21
+ mode: "live" | "stub";
22
+ liveMode: boolean;
23
+ engineReady: boolean;
24
+ };
25
+ normalized: {
26
+ objective: string;
27
+ workingDirectory: string;
28
+ engine: MartinEngine;
29
+ model?: string;
30
+ budget: {
31
+ maxUsd: number;
32
+ softLimitUsd: number;
33
+ maxIterations: number;
34
+ maxTokens: number;
35
+ };
36
+ verificationPlan: string[];
37
+ allowedPaths?: string[];
38
+ deniedPaths?: string[];
39
+ workspaceId: string;
40
+ projectId: string;
41
+ };
42
+ execution: {
43
+ requestedEngine: MartinEngine;
44
+ engineAvailability: {
45
+ available: boolean;
46
+ detail: string;
47
+ resolvedPath?: string;
48
+ };
49
+ runsRoot: string;
50
+ pathScope: {
51
+ repoRoot: string;
52
+ allowedPathsCount: number;
53
+ deniedPathsCount: number;
54
+ hasScopeConflicts: boolean;
55
+ };
56
+ expectedRunLayout: {
57
+ runDirectoryPattern: string;
58
+ loopRecordPathPattern: string;
59
+ };
60
+ };
61
+ }
62
+ export declare function martinPreflightTool(input: MartinPreflightInput): Promise<MartinPreflightOutput>;
@@ -0,0 +1,76 @@
1
+ import { DEFAULT_BUDGET } from "../vendor/contracts/index.js";
2
+ import { resolveRunsRoot } from "../vendor/core/index.js";
3
+ import { resolveSafeRepoRoot } from "../server-validation.js";
4
+ import { formatUsd, getEngineAvailability, resolveExecutionMode } from "./tool-support.js";
5
+ export async function martinPreflightTool(input) {
6
+ const executionMode = resolveExecutionMode();
7
+ const workingDirectory = resolveSafeRepoRoot(input.workingDirectory);
8
+ const engine = input.engine ?? "claude";
9
+ const engineAvailability = getEngineAvailability(engine);
10
+ const warnings = [];
11
+ const allowedPaths = input.allowedPaths ?? [];
12
+ const deniedPaths = input.deniedPaths ?? [];
13
+ const overlappingScopes = allowedPaths.filter((candidate) => deniedPaths.includes(candidate));
14
+ const budget = {
15
+ ...DEFAULT_BUDGET,
16
+ ...(input.maxUsd !== undefined ? { maxUsd: input.maxUsd } : {}),
17
+ ...(input.maxIterations !== undefined ? { maxIterations: input.maxIterations } : {}),
18
+ ...(input.maxTokens !== undefined ? { maxTokens: input.maxTokens } : {})
19
+ };
20
+ if (!executionMode.liveMode) {
21
+ warnings.push("Stub mode is active; preflight only proves configuration shape, not live CLI readiness.");
22
+ }
23
+ else if (!engineAvailability.available) {
24
+ warnings.push(`Requested engine '${engine}' is not available on PATH.`);
25
+ }
26
+ if ((input.verificationPlan?.length ?? 0) === 0) {
27
+ warnings.push("No verificationPlan was provided; Martin can run, but completion confidence will be lower.");
28
+ }
29
+ if ((input.allowedPaths?.length ?? 0) === 0) {
30
+ warnings.push("No allowedPaths were provided; Martin will rely on the broader repo root scope.");
31
+ }
32
+ if (overlappingScopes.length > 0) {
33
+ warnings.push(`Some path patterns appear in both allowedPaths and deniedPaths: ${overlappingScopes.join(", ")}.`);
34
+ }
35
+ const ok = !executionMode.liveMode || engineAvailability.available;
36
+ return {
37
+ ok,
38
+ summary: ok
39
+ ? `Preflight ready for ${engine} in ${workingDirectory} with a ${formatUsd(budget.maxUsd)} budget cap.`
40
+ : `Preflight blocked: ${engine} is not available for live execution.`,
41
+ warnings,
42
+ readiness: {
43
+ mode: executionMode.mode,
44
+ liveMode: executionMode.liveMode,
45
+ engineReady: !executionMode.liveMode || engineAvailability.available
46
+ },
47
+ normalized: {
48
+ objective: input.objective,
49
+ workingDirectory,
50
+ engine,
51
+ ...(input.model ? { model: input.model } : {}),
52
+ budget,
53
+ verificationPlan: input.verificationPlan ?? [],
54
+ ...(input.allowedPaths ? { allowedPaths: input.allowedPaths } : {}),
55
+ ...(input.deniedPaths ? { deniedPaths: input.deniedPaths } : {}),
56
+ workspaceId: input.workspaceId ?? "ws_mcp",
57
+ projectId: input.projectId ?? "proj_mcp"
58
+ },
59
+ execution: {
60
+ requestedEngine: engine,
61
+ engineAvailability,
62
+ runsRoot: resolveRunsRoot(process.env),
63
+ pathScope: {
64
+ repoRoot: workingDirectory,
65
+ allowedPathsCount: allowedPaths.length,
66
+ deniedPathsCount: deniedPaths.length,
67
+ hasScopeConflicts: overlappingScopes.length > 0
68
+ },
69
+ expectedRunLayout: {
70
+ runDirectoryPattern: "<runsRoot>/<loopId>/",
71
+ loopRecordPathPattern: "<runsRoot>/<loopId>/loop-record.json"
72
+ }
73
+ }
74
+ };
75
+ }
76
+ //# sourceMappingURL=preflight.js.map
@@ -0,0 +1,37 @@
1
+ export type MartinEngine = "claude" | "codex";
2
+ export interface LoopPreview {
3
+ loopId: string;
4
+ title: string;
5
+ objective: string;
6
+ status: string;
7
+ lifecycleState: string;
8
+ createdAt?: string;
9
+ updatedAt?: string;
10
+ attempts: number;
11
+ costUsd: number;
12
+ avoidedUsd: number;
13
+ pressure: string;
14
+ shouldStop: boolean;
15
+ remainingBudgetUsd: number;
16
+ remainingIterations: number;
17
+ remainingTokens: number;
18
+ }
19
+ export interface CliAvailability {
20
+ available: boolean;
21
+ detail: string;
22
+ resolvedPath?: string;
23
+ }
24
+ export interface ExecutionMode {
25
+ liveMode: boolean;
26
+ mode: "live" | "stub";
27
+ }
28
+ export interface RunStoreInspection {
29
+ exists: boolean;
30
+ loopCount: number;
31
+ latestRun?: LoopPreview;
32
+ warnings: string[];
33
+ }
34
+ export declare function resolveExecutionMode(): ExecutionMode;
35
+ export declare function getEngineAvailability(engine: MartinEngine): CliAvailability;
36
+ export declare function formatUsd(value: number): string;
37
+ export declare function inspectRunsRoot(runsRoot?: string): Promise<RunStoreInspection>;
@@ -0,0 +1,110 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { readdir, stat } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { evaluateCostGovernor, readLatestLoopRecordFromFile, resolveRunsRoot } from "../vendor/core/index.js";
5
+ export function resolveExecutionMode() {
6
+ const liveMode = process.env.MARTIN_LIVE !== "false";
7
+ return {
8
+ liveMode,
9
+ mode: liveMode ? "live" : "stub"
10
+ };
11
+ }
12
+ export function getEngineAvailability(engine) {
13
+ const locator = process.platform === "win32" ? "where.exe" : "which";
14
+ const result = spawnSync(locator, [engine], {
15
+ encoding: "utf8",
16
+ stdio: ["ignore", "pipe", "pipe"]
17
+ });
18
+ const resolvedPath = result.status === 0
19
+ ? (result.stdout ?? "")
20
+ .split(/\r?\n/u)
21
+ .map((line) => line.trim())
22
+ .find(Boolean)
23
+ : undefined;
24
+ return result.status === 0
25
+ ? {
26
+ available: true,
27
+ detail: `${engine} is available on PATH.`,
28
+ ...(resolvedPath ? { resolvedPath } : {})
29
+ }
30
+ : {
31
+ available: false,
32
+ detail: `${engine} is not available on PATH.`
33
+ };
34
+ }
35
+ export function formatUsd(value) {
36
+ return `$${value.toFixed(2)}`;
37
+ }
38
+ export async function inspectRunsRoot(runsRoot = resolveRunsRoot(process.env)) {
39
+ let exists = false;
40
+ try {
41
+ exists = (await stat(runsRoot)).isDirectory();
42
+ }
43
+ catch {
44
+ exists = false;
45
+ }
46
+ if (!exists) {
47
+ return {
48
+ exists: false,
49
+ loopCount: 0,
50
+ warnings: []
51
+ };
52
+ }
53
+ const warnings = [];
54
+ const loops = [];
55
+ const entries = await readdir(runsRoot, { withFileTypes: true });
56
+ for (const entry of entries) {
57
+ if (!entry.isDirectory()) {
58
+ continue;
59
+ }
60
+ const loopRecordPath = join(runsRoot, entry.name, "loop-record.json");
61
+ try {
62
+ const loop = await readLatestLoopRecordFromFile(loopRecordPath);
63
+ if (!loop) {
64
+ continue;
65
+ }
66
+ const costState = evaluateCostGovernor({
67
+ budget: loop.budget,
68
+ cost: {
69
+ actualUsd: loop.cost.actualUsd,
70
+ avoidedUsd: loop.cost.avoidedUsd ?? 0,
71
+ tokensIn: loop.cost.tokensIn,
72
+ tokensOut: loop.cost.tokensOut
73
+ },
74
+ attemptsUsed: loop.attempts.length
75
+ });
76
+ loops.push({
77
+ loopId: loop.loopId,
78
+ title: loop.task?.title ?? loop.loopId,
79
+ objective: loop.task?.objective ?? "Loop record summary",
80
+ status: loop.status,
81
+ lifecycleState: loop.lifecycleState,
82
+ ...(loop.createdAt ? { createdAt: loop.createdAt } : {}),
83
+ ...(loop.updatedAt ? { updatedAt: loop.updatedAt } : {}),
84
+ attempts: loop.attempts.length,
85
+ costUsd: loop.cost.actualUsd,
86
+ avoidedUsd: loop.cost.avoidedUsd ?? 0,
87
+ pressure: costState.pressure,
88
+ shouldStop: costState.shouldStop,
89
+ remainingBudgetUsd: costState.remainingBudgetUsd,
90
+ remainingIterations: costState.remainingIterations,
91
+ remainingTokens: costState.remainingTokens
92
+ });
93
+ }
94
+ catch {
95
+ warnings.push(`Skipped unreadable loop record for '${entry.name}'.`);
96
+ }
97
+ }
98
+ loops.sort((left, right) => {
99
+ const leftTime = Date.parse(left.updatedAt ?? left.createdAt ?? "");
100
+ const rightTime = Date.parse(right.updatedAt ?? right.createdAt ?? "");
101
+ return (Number.isFinite(rightTime) ? rightTime : 0) - (Number.isFinite(leftTime) ? leftTime : 0);
102
+ });
103
+ return {
104
+ exists: true,
105
+ loopCount: loops.length,
106
+ ...(loops[0] ? { latestRun: loops[0] } : {}),
107
+ warnings
108
+ };
109
+ }
110
+ //# sourceMappingURL=tool-support.js.map
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@martinloop/mcp",
3
- "version": "0.1.2",
4
- "mcpName": "io.github.keesan12/martin-loop",
3
+ "version": "0.1.4",
4
+ "mcpName": "io.github.Keesan12/martin-loop",
5
5
  "private": false,
6
6
  "type": "module",
7
- "description": "Governed MCP server for AI coding agents with budgets, verifier gates, policy checks, and audit trails.",
8
- "license": "MIT",
7
+ "description": "Governed MCP server for AI coding agents with budgets, verifier gates, and inspectable runs.",
8
+ "license": "Apache-2.0",
9
9
  "author": "Vakeesan Mahalingam and Gobi Shanthan",
10
10
  "homepage": "https://martinloop.com/",
11
11
  "repository": {
@@ -22,24 +22,22 @@
22
22
  "martin-loop",
23
23
  "ai-agent",
24
24
  "claude",
25
- "codex"
25
+ "codex",
26
+ "martin_doctor",
27
+ "martin_preflight"
26
28
  ],
27
- "main": "./dist/server.js",
28
- "types": "./dist/server.d.ts",
29
29
  "bin": {
30
30
  "mcp": "./dist/server.js",
31
31
  "martin-loop-mcp": "./dist/server.js"
32
32
  },
33
33
  "exports": {
34
- ".": {
35
- "types": "./dist/server.d.ts",
36
- "default": "./dist/server.js"
37
- },
34
+ "./server.json": "./server.json",
38
35
  "./package.json": "./package.json"
39
36
  },
40
37
  "files": [
41
38
  "dist",
42
- "README.md"
39
+ "README.md",
40
+ "server.json"
43
41
  ],
44
42
  "engines": {
45
43
  "node": ">=20.0.0"
@@ -52,6 +50,8 @@
52
50
  "prepack": "pnpm build",
53
51
  "smoke:pack": "node ./scripts/smoke-package.mjs",
54
52
  "smoke:published": "node ./scripts/smoke-published-package.mjs",
53
+ "smoke:published:pack": "node ./scripts/smoke-published-package.mjs --package-spec=pack",
54
+ "verify:release": "node --test ../../scripts/tests/publish-mcp-workflow.test.mjs ../../scripts/tests/mcp-publish-reliability.test.mjs ../../scripts/tests/mcp-release-docs.test.mjs",
55
55
  "test": "vitest run",
56
56
  "lint": "tsc -p tsconfig.json --noEmit",
57
57
  "start": "node dist/server.js"
package/server.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
+ "name": "io.github.Keesan12/martin-loop",
4
+ "title": "Martin Loop",
5
+ "description": "Governed MCP server for AI coding agents with budgets, verifier gates, and inspectable runs.",
6
+ "repository": {
7
+ "url": "https://github.com/Keesan12/martin-loop",
8
+ "source": "github"
9
+ },
10
+ "version": "0.1.4",
11
+ "packages": [
12
+ {
13
+ "registryType": "npm",
14
+ "identifier": "@martinloop/mcp",
15
+ "version": "0.1.4",
16
+ "transport": {
17
+ "type": "stdio"
18
+ }
19
+ }
20
+ ]
21
+ }