@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.
- package/README.md +181 -41
- package/dist/server-validation.d.ts +10 -0
- package/dist/server-validation.js +234 -0
- package/dist/server.js +59 -15
- package/dist/tools/get-status.d.ts +10 -2
- package/dist/tools/get-status.js +11 -4
- package/dist/tools/inspect-loop.d.ts +4 -2
- package/dist/tools/inspect-loop.js +4 -7
- package/dist/tools/run-loop.d.ts +2 -0
- package/dist/tools/run-loop.js +10 -3
- package/dist/tools/run-store.d.ts +20 -0
- package/dist/tools/run-store.js +109 -0
- package/dist/vendor/adapters/claude-cli.d.ts +19 -4
- package/dist/vendor/adapters/claude-cli.js +55 -24
- package/dist/vendor/adapters/cli-bridge.d.ts +1 -0
- package/dist/vendor/adapters/cli-bridge.js +154 -28
- package/dist/vendor/adapters/index.d.ts +1 -0
- package/dist/vendor/adapters/index.js +1 -0
- package/dist/vendor/adapters/verifier-only.d.ts +7 -0
- package/dist/vendor/adapters/verifier-only.js +57 -0
- package/dist/vendor/contracts/index.d.ts +3 -1
- package/dist/vendor/core/compiler.d.ts +2 -0
- package/dist/vendor/core/compiler.js +10 -4
- package/dist/vendor/core/context-integrity.d.ts +26 -0
- package/dist/vendor/core/context-integrity.js +56 -0
- package/dist/vendor/core/index.d.ts +7 -4
- package/dist/vendor/core/index.js +222 -64
- package/dist/vendor/core/persistence/index.d.ts +2 -0
- package/dist/vendor/core/persistence/index.js +1 -0
- package/dist/vendor/core/persistence/runs-reader.d.ts +52 -0
- package/dist/vendor/core/persistence/runs-reader.js +84 -0
- package/dist/vendor/core/persistence/store.d.ts +6 -1
- package/dist/vendor/core/persistence/store.js +5 -0
- package/dist/vendor/core/policy.d.ts +6 -0
- package/package.json +17 -12
- package/server.json +21 -0
package/README.md
CHANGED
|
@@ -1,59 +1,199 @@
|
|
|
1
1
|
# @martinloop/mcp
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
- `martin_run`
|
|
8
|
-
- `martin_inspect`
|
|
9
|
-
- `martin_status`
|
|
10
|
-
|
|
11
|
-
##
|
|
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
|
-
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
```sh
|
|
45
|
-
mcp-publisher login github
|
|
46
|
-
mcp-publisher publish
|
|
47
|
-
```
|
|
48
|
-
|
|
49
|
-
##
|
|
50
|
-
|
|
51
|
-
From the repository root:
|
|
52
|
-
|
|
53
|
-
```sh
|
|
54
|
-
pnpm --filter @martinloop/mcp
|
|
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
|
-
|
|
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
|
-
|
|
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: "
|
|
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: "
|
|
66
|
+
type: "integer",
|
|
67
|
+
exclusiveMinimum: 0,
|
|
61
68
|
description: "Maximum number of loop attempts. Defaults to 8."
|
|
62
69
|
},
|
|
63
70
|
maxTokens: {
|
|
64
|
-
type: "
|
|
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
|
|
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: "
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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>;
|