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