@martinloop/mcp 0.1.3 → 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 +26 -5
- package/dist/server-validation.d.ts +1 -1
- package/dist/server-validation.js +52 -0
- package/dist/server.d.ts +6 -4
- package/dist/server.js +107 -4
- package/dist/tools/doctor.d.ts +35 -0
- package/dist/tools/doctor.js +56 -0
- package/dist/tools/preflight.d.ts +62 -0
- package/dist/tools/preflight.js +76 -0
- package/dist/tools/tool-support.d.ts +37 -0
- package/dist/tools/tool-support.js +110 -0
- package/package.json +8 -5
- package/server.json +4 -4
package/README.md
CHANGED
|
@@ -2,12 +2,21 @@
|
|
|
2
2
|
|
|
3
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
|
-
`@martinloop/mcp` exposes
|
|
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
|
|
|
13
|
+
Recommended flow:
|
|
14
|
+
|
|
15
|
+
1. `martin_doctor`
|
|
16
|
+
2. `martin_preflight`
|
|
17
|
+
3. `martin_run`
|
|
18
|
+
4. `martin_inspect` or `martin_status`
|
|
19
|
+
|
|
11
20
|
## What This Server Is For
|
|
12
21
|
|
|
13
22
|
Use this MCP when a host already knows how to delegate coding work, but you want Martin Loop to bound that work with:
|
|
@@ -31,14 +40,20 @@ Run the packaged server directly:
|
|
|
31
40
|
npx -y @martinloop/mcp
|
|
32
41
|
```
|
|
33
42
|
|
|
43
|
+
Add it to Codex:
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
codex mcp add martin-loop -- npx -y @martinloop/mcp
|
|
47
|
+
```
|
|
48
|
+
|
|
34
49
|
Add it to Claude Code:
|
|
35
50
|
|
|
36
51
|
```sh
|
|
37
52
|
# macOS/Linux
|
|
38
|
-
claude mcp add --scope user martin-loop -- npx -y @martinloop/mcp
|
|
53
|
+
claude mcp add --transport stdio --scope user martin-loop -- npx -y @martinloop/mcp
|
|
39
54
|
|
|
40
55
|
# Windows PowerShell/cmd
|
|
41
|
-
claude mcp add --scope user martin-loop cmd /c
|
|
56
|
+
claude mcp add --transport stdio --scope user martin-loop -- cmd /c npx -y @martinloop/mcp
|
|
42
57
|
```
|
|
43
58
|
|
|
44
59
|
Generic stdio configuration:
|
|
@@ -75,6 +90,8 @@ MARTIN_LIVE=false npx -y @martinloop/mcp
|
|
|
75
90
|
|
|
76
91
|
| Tool | Purpose | Required input | Important optional input | Notes |
|
|
77
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. |
|
|
78
95
|
| `martin_run` | Run a governed coding loop | `objective` | `workingDirectory`, `engine`, `model`, `maxUsd`, `maxIterations`, `maxTokens`, `verificationPlan`, `allowedPaths`, `deniedPaths`, `workspaceId`, `projectId` | Unknown arguments are rejected. |
|
|
79
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. |
|
|
80
97
|
| `martin_status` | Report budget pressure and stop conditions | exactly one of `loopJson`, `file`, `loopId`, or `latest` | `runsDir` | `latest` must be `true` when used. |
|
|
@@ -169,7 +186,7 @@ The registry manifest artifact for this package is `server.json`. In this reposi
|
|
|
169
186
|
Current metadata:
|
|
170
187
|
|
|
171
188
|
- npm package: `@martinloop/mcp`
|
|
172
|
-
- registry server name: `io.github.
|
|
189
|
+
- registry server name: `io.github.Keesan12/martin-loop`
|
|
173
190
|
- manifest artifact name: `server.json`
|
|
174
191
|
|
|
175
192
|
Official MCP Registry publication is separate from npm publication. After publishing the package to npm, run the publisher from `packages/mcp`:
|
|
@@ -188,12 +205,16 @@ pnpm --filter @martinloop/mcp lint
|
|
|
188
205
|
pnpm --filter @martinloop/mcp test
|
|
189
206
|
pnpm --filter @martinloop/mcp build
|
|
190
207
|
pnpm --filter @martinloop/mcp smoke:pack
|
|
208
|
+
pnpm --filter @martinloop/mcp smoke:published:pack
|
|
209
|
+
pnpm --filter @martinloop/mcp verify:release
|
|
191
210
|
pnpm --filter @martinloop/mcp smoke:published
|
|
192
211
|
```
|
|
193
212
|
|
|
194
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
|
|
195
216
|
- `smoke:published` verifies the npm-installed artifact through `npm install` plus live MCP tool calls
|
|
196
217
|
|
|
197
218
|
## Version Notes
|
|
198
219
|
|
|
199
|
-
The root `CHANGELOG.md` is repo-wide and includes non-MCP changes. For the `@martinloop/mcp` surface, prefer this README
|
|
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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* Exposes five tools over the Model Context Protocol (stdio transport):
|
|
6
|
+
* martin_doctor — inspect local readiness and run-store health
|
|
7
|
+
* martin_preflight — normalize 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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* Exposes five tools over the Model Context Protocol (stdio transport):
|
|
6
|
+
* martin_doctor — inspect local readiness and run-store health
|
|
7
|
+
* martin_preflight — normalize 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
|
|
@@ -21,8 +23,10 @@ import { createRequire } from "node:module";
|
|
|
21
23
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
22
24
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
23
25
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
26
|
+
import { martinDoctorTool } from "./tools/doctor.js";
|
|
24
27
|
import { getStatusTool } from "./tools/get-status.js";
|
|
25
28
|
import { inspectLoopTool } from "./tools/inspect-loop.js";
|
|
29
|
+
import { martinPreflightTool } from "./tools/preflight.js";
|
|
26
30
|
import { runLoopTool } from "./tools/run-loop.js";
|
|
27
31
|
import { sanitizeToolErrorMessage, validateToolInput } from "./server-validation.js";
|
|
28
32
|
const require = createRequire(import.meta.url);
|
|
@@ -33,6 +37,95 @@ const server = new Server({ name: "martin-loop", version: packageJson.version },
|
|
|
33
37
|
// ---------------------------------------------------------------------------
|
|
34
38
|
server.setRequestHandler(ListToolsRequestSchema, () => ({
|
|
35
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
|
+
},
|
|
36
129
|
{
|
|
37
130
|
name: "martin_run",
|
|
38
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.",
|
|
@@ -161,6 +254,16 @@ server.setRequestHandler(ListToolsRequestSchema, () => ({
|
|
|
161
254
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
162
255
|
const { name, arguments: args } = request.params;
|
|
163
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
|
+
}
|
|
164
267
|
if (name === "martin_run") {
|
|
165
268
|
const input = validateToolInput("martin_run", args);
|
|
166
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.
|
|
4
|
-
"mcpName": "io.github.
|
|
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,
|
|
8
|
-
"license": "
|
|
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,7 +22,9 @@
|
|
|
22
22
|
"martin-loop",
|
|
23
23
|
"ai-agent",
|
|
24
24
|
"claude",
|
|
25
|
-
"codex"
|
|
25
|
+
"codex",
|
|
26
|
+
"martin_doctor",
|
|
27
|
+
"martin_preflight"
|
|
26
28
|
],
|
|
27
29
|
"bin": {
|
|
28
30
|
"mcp": "./dist/server.js",
|
|
@@ -49,6 +51,7 @@
|
|
|
49
51
|
"smoke:pack": "node ./scripts/smoke-package.mjs",
|
|
50
52
|
"smoke:published": "node ./scripts/smoke-published-package.mjs",
|
|
51
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",
|
|
52
55
|
"test": "vitest run",
|
|
53
56
|
"lint": "tsc -p tsconfig.json --noEmit",
|
|
54
57
|
"start": "node dist/server.js"
|
package/server.json
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
-
"name": "io.github.
|
|
3
|
+
"name": "io.github.Keesan12/martin-loop",
|
|
4
4
|
"title": "Martin Loop",
|
|
5
|
-
"description": "Governed MCP server for AI coding agents with budgets, verifier gates,
|
|
5
|
+
"description": "Governed MCP server for AI coding agents with budgets, verifier gates, and inspectable runs.",
|
|
6
6
|
"repository": {
|
|
7
7
|
"url": "https://github.com/Keesan12/martin-loop",
|
|
8
8
|
"source": "github"
|
|
9
9
|
},
|
|
10
|
-
"version": "0.1.
|
|
10
|
+
"version": "0.1.4",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"identifier": "@martinloop/mcp",
|
|
15
|
-
"version": "0.1.
|
|
15
|
+
"version": "0.1.4",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
}
|