@martinloop/mcp 0.2.5 → 0.2.7
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 +101 -138
- package/dist/discovery-metadata.d.ts +10 -5
- package/dist/discovery-metadata.js +95 -5
- package/dist/package-version.d.ts +1 -1
- package/dist/package-version.js +1 -1
- package/dist/prompts.js +93 -1
- package/dist/resources.d.ts +8 -0
- package/dist/resources.js +245 -14
- package/dist/server-validation.d.ts +1 -1
- package/dist/server-validation.js +116 -0
- package/dist/server.js +361 -3
- package/dist/tools/doctor.d.ts +2 -0
- package/dist/tools/doctor.js +6 -2
- package/dist/tools/eval.d.ts +24 -0
- package/dist/tools/eval.js +65 -0
- package/dist/tools/get-status.d.ts +8 -0
- package/dist/tools/get-status.js +18 -0
- package/dist/tools/logs.d.ts +25 -0
- package/dist/tools/logs.js +49 -0
- package/dist/tools/plan.d.ts +20 -0
- package/dist/tools/plan.js +10 -0
- package/dist/tools/pr-tools.d.ts +31 -0
- package/dist/tools/pr-tools.js +111 -0
- package/dist/tools/preflight.d.ts +10 -0
- package/dist/tools/preflight.js +11 -2
- package/dist/tools/run-controls.d.ts +36 -0
- package/dist/tools/run-controls.js +88 -0
- package/dist/tools/run-dossier.d.ts +14 -0
- package/dist/tools/run-dossier.js +61 -1
- package/dist/tools/run-loop.js +21 -2
- package/dist/tools/tool-errors.d.ts +1 -1
- package/dist/tools/tool-support.js +28 -1
- package/dist/tools/workflow-governance.d.ts +133 -0
- package/dist/tools/workflow-governance.js +581 -0
- package/dist/vendor/adapters/cli-bridge.d.ts +5 -0
- package/dist/vendor/adapters/cli-bridge.js +16 -8
- package/dist/vendor/adapters/index.d.ts +2 -1
- package/dist/vendor/adapters/index.js +2 -0
- package/dist/vendor/adapters/openai-compatible.d.ts +47 -0
- package/dist/vendor/adapters/openai-compatible.js +242 -0
- package/dist/workflow-state.d.ts +25 -0
- package/dist/workflow-state.js +102 -0
- package/package.json +3 -3
- package/server.json +2 -2
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI-compatible adapter for MartinLoop.
|
|
3
|
+
*
|
|
4
|
+
* Routes agent execution to any endpoint that implements the OpenAI
|
|
5
|
+
* Chat Completions API (`POST /v1/chat/completions`). This covers:
|
|
6
|
+
*
|
|
7
|
+
* Hosted via OpenRouter / Together.ai / Fireworks.ai:
|
|
8
|
+
* DeepSeek-V3, DeepSeek-R1, Qwen3-235B, Mistral Large, Codestral,
|
|
9
|
+
* Kimi k2, Nemotron-70B, and hundreds more.
|
|
10
|
+
*
|
|
11
|
+
* Local via Ollama / LM Studio / llama.cpp:
|
|
12
|
+
* Llama 3.x, Mistral 7B, Phi-4, Gemma 3, any GGUF model.
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* MARTIN_OPENAI_BASE_URL=https://openrouter.ai/api
|
|
16
|
+
* MARTIN_OPENAI_API_KEY=sk-or-...
|
|
17
|
+
* MARTIN_OPENAI_MODEL=deepseek/deepseek-chat
|
|
18
|
+
* martin run "fix the bug" --engine openai
|
|
19
|
+
*
|
|
20
|
+
* Or for Ollama:
|
|
21
|
+
* MARTIN_OPENAI_BASE_URL=http://localhost:11434
|
|
22
|
+
* MARTIN_OPENAI_MODEL=llama3.3
|
|
23
|
+
* martin run "fix the bug" --engine openai
|
|
24
|
+
*/
|
|
25
|
+
import type { MartinAdapter } from "../core/index.js";
|
|
26
|
+
export interface OpenAiCompatibleAdapterOptions {
|
|
27
|
+
/** Base URL of the OpenAI-compatible API. No trailing slash. */
|
|
28
|
+
baseUrl: string;
|
|
29
|
+
/** API key. Empty string for local (Ollama/LM Studio) endpoints. */
|
|
30
|
+
apiKey?: string;
|
|
31
|
+
/** Model identifier passed as-is to the API (e.g. "deepseek/deepseek-chat"). */
|
|
32
|
+
model: string;
|
|
33
|
+
/**
|
|
34
|
+
* System prompt prepended before the MartinLoop task prompt.
|
|
35
|
+
* Default instructs the model to act as a focused coding assistant.
|
|
36
|
+
*/
|
|
37
|
+
systemPrompt?: string;
|
|
38
|
+
/** Request timeout in milliseconds. Default: 300_000 (5 min). */
|
|
39
|
+
timeoutMs?: number;
|
|
40
|
+
/** Verifier timeout in milliseconds. Default: 60_000. */
|
|
41
|
+
verifyTimeoutMs?: number;
|
|
42
|
+
/** Working directory for git artifact collection and verification. */
|
|
43
|
+
workingDirectory?: string;
|
|
44
|
+
/** Optional fetch override for testing. */
|
|
45
|
+
fetchImpl?: typeof fetch;
|
|
46
|
+
}
|
|
47
|
+
export declare function createOpenAiCompatibleAdapter(options: OpenAiCompatibleAdapterOptions): MartinAdapter;
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI-compatible adapter for MartinLoop.
|
|
3
|
+
*
|
|
4
|
+
* Routes agent execution to any endpoint that implements the OpenAI
|
|
5
|
+
* Chat Completions API (`POST /v1/chat/completions`). This covers:
|
|
6
|
+
*
|
|
7
|
+
* Hosted via OpenRouter / Together.ai / Fireworks.ai:
|
|
8
|
+
* DeepSeek-V3, DeepSeek-R1, Qwen3-235B, Mistral Large, Codestral,
|
|
9
|
+
* Kimi k2, Nemotron-70B, and hundreds more.
|
|
10
|
+
*
|
|
11
|
+
* Local via Ollama / LM Studio / llama.cpp:
|
|
12
|
+
* Llama 3.x, Mistral 7B, Phi-4, Gemma 3, any GGUF model.
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* MARTIN_OPENAI_BASE_URL=https://openrouter.ai/api
|
|
16
|
+
* MARTIN_OPENAI_API_KEY=sk-or-...
|
|
17
|
+
* MARTIN_OPENAI_MODEL=deepseek/deepseek-chat
|
|
18
|
+
* martin run "fix the bug" --engine openai
|
|
19
|
+
*
|
|
20
|
+
* Or for Ollama:
|
|
21
|
+
* MARTIN_OPENAI_BASE_URL=http://localhost:11434
|
|
22
|
+
* MARTIN_OPENAI_MODEL=llama3.3
|
|
23
|
+
* martin run "fix the bug" --engine openai
|
|
24
|
+
*/
|
|
25
|
+
import { runVerification, readGitExecutionArtifacts } from "./cli-bridge.js";
|
|
26
|
+
import { createAdapterCapabilities, normalizeUsage } from "./runtime-support.js";
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// OpenRouter/OpenAI-compatible model pricing ($/1K tokens)
|
|
29
|
+
// Automatically used when baseUrl contains openrouter.ai or known providers.
|
|
30
|
+
// Defaults to a conservative blended estimate for unknown models.
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
const KNOWN_MODEL_PRICING = {
|
|
33
|
+
// DeepSeek
|
|
34
|
+
"deepseek/deepseek-chat": { inputPer1K: 0.00027, outputPer1K: 0.0011 },
|
|
35
|
+
"deepseek/deepseek-r1": { inputPer1K: 0.0008, outputPer1K: 0.0032 },
|
|
36
|
+
"deepseek/deepseek-coder": { inputPer1K: 0.00014, outputPer1K: 0.00028 },
|
|
37
|
+
// Qwen
|
|
38
|
+
"qwen/qwen3-235b-a22b": { inputPer1K: 0.00022, outputPer1K: 0.00088 },
|
|
39
|
+
"qwen/qwen3-32b": { inputPer1K: 0.00009, outputPer1K: 0.00009 },
|
|
40
|
+
"qwen/qwen-2.5-coder-32b-instruct": { inputPer1K: 0.00007, outputPer1K: 0.00007 },
|
|
41
|
+
// Mistral
|
|
42
|
+
"mistralai/codestral-latest": { inputPer1K: 0.0003, outputPer1K: 0.0009 },
|
|
43
|
+
"mistralai/mistral-large": { inputPer1K: 0.003, outputPer1K: 0.009 },
|
|
44
|
+
"mistralai/mistral-small": { inputPer1K: 0.0001, outputPer1K: 0.0003 },
|
|
45
|
+
// Kimi
|
|
46
|
+
"moonshotai/kimi-k2": { inputPer1K: 0.00065, outputPer1K: 0.0026 },
|
|
47
|
+
// Nemotron
|
|
48
|
+
"nvidia/llama-3.1-nemotron-70b-instruct": { inputPer1K: 0.00012, outputPer1K: 0.0003 },
|
|
49
|
+
// Llama (via OpenRouter)
|
|
50
|
+
"meta-llama/llama-3.3-70b-instruct": { inputPer1K: 0.00012, outputPer1K: 0.0003 },
|
|
51
|
+
"meta-llama/llama-3.1-405b-instruct": { inputPer1K: 0.0008, outputPer1K: 0.0008 },
|
|
52
|
+
};
|
|
53
|
+
const FALLBACK_INPUT_PER_1K = 0.0003;
|
|
54
|
+
const FALLBACK_OUTPUT_PER_1K = 0.0012;
|
|
55
|
+
const CHARS_PER_TOKEN = 4;
|
|
56
|
+
function estimateCost(model, inputChars, outputChars) {
|
|
57
|
+
const pricing = KNOWN_MODEL_PRICING[model] ?? {
|
|
58
|
+
inputPer1K: FALLBACK_INPUT_PER_1K,
|
|
59
|
+
outputPer1K: FALLBACK_OUTPUT_PER_1K
|
|
60
|
+
};
|
|
61
|
+
const tokensIn = Math.ceil(inputChars / CHARS_PER_TOKEN);
|
|
62
|
+
const tokensOut = Math.ceil(outputChars / CHARS_PER_TOKEN);
|
|
63
|
+
const actualUsd = (tokensIn / 1000) * pricing.inputPer1K + (tokensOut / 1000) * pricing.outputPer1K;
|
|
64
|
+
return { tokensIn, tokensOut, actualUsd };
|
|
65
|
+
}
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Prompt builder
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
const DEFAULT_SYSTEM_PROMPT = `You are an expert software engineer executing a governed coding task.
|
|
70
|
+
Follow these rules exactly:
|
|
71
|
+
- Read the task description carefully and implement only what is asked.
|
|
72
|
+
- Do not add features, refactors, or improvements beyond the stated task.
|
|
73
|
+
- If the task asks you to write or modify code, output the complete file content with changes applied.
|
|
74
|
+
- Be precise, minimal, and test-backed in all changes.
|
|
75
|
+
- State what you changed and why at the end of your response.`;
|
|
76
|
+
function buildPrompt(request) {
|
|
77
|
+
const lines = [
|
|
78
|
+
`TASK: ${request.context.taskTitle}`,
|
|
79
|
+
``,
|
|
80
|
+
`OBJECTIVE:`,
|
|
81
|
+
request.context.objective,
|
|
82
|
+
``
|
|
83
|
+
];
|
|
84
|
+
if (request.context.focus) {
|
|
85
|
+
lines.push(`FOCUS: ${request.context.focus}`, ``);
|
|
86
|
+
}
|
|
87
|
+
if (request.context.verificationPlan.length > 0) {
|
|
88
|
+
lines.push(`VERIFICATION COMMANDS (must pass after your changes):`, ...request.context.verificationPlan.map((cmd) => ` ${cmd}`), ``);
|
|
89
|
+
}
|
|
90
|
+
if (request.previousAttempts.length > 0) {
|
|
91
|
+
const last = request.previousAttempts.at(-1);
|
|
92
|
+
if (last) {
|
|
93
|
+
lines.push(`PREVIOUS ATTEMPT SUMMARY:`, last.summary ?? "", ``);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
lines.push(`BUDGET REMAINING: $${request.context.remainingBudgetUsd.toFixed(4)} | Iterations left: ${request.context.remainingIterations}`);
|
|
97
|
+
return lines.join("\n");
|
|
98
|
+
}
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Factory
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
export function createOpenAiCompatibleAdapter(options) {
|
|
103
|
+
const workingDirectory = options.workingDirectory ?? process.cwd();
|
|
104
|
+
const timeoutMs = options.timeoutMs ?? 300_000;
|
|
105
|
+
const verifyTimeoutMs = options.verifyTimeoutMs ?? 60_000;
|
|
106
|
+
const systemPrompt = options.systemPrompt ?? DEFAULT_SYSTEM_PROMPT;
|
|
107
|
+
const fetchFn = options.fetchImpl ?? globalThis.fetch;
|
|
108
|
+
return {
|
|
109
|
+
adapterId: `openai-compatible:${options.model}`,
|
|
110
|
+
kind: "direct-provider",
|
|
111
|
+
label: `OpenAI-compatible: ${options.model}`,
|
|
112
|
+
metadata: {
|
|
113
|
+
providerId: "openai-compatible",
|
|
114
|
+
model: options.model,
|
|
115
|
+
transport: "http",
|
|
116
|
+
capabilities: createAdapterCapabilities({
|
|
117
|
+
preflight: true,
|
|
118
|
+
usageSettlement: true,
|
|
119
|
+
diffArtifacts: true
|
|
120
|
+
})
|
|
121
|
+
},
|
|
122
|
+
async execute(request) {
|
|
123
|
+
const prompt = buildPrompt(request);
|
|
124
|
+
const estimated = estimateCost(options.model, prompt.length, 2000);
|
|
125
|
+
// Preflight: bail if projected cost exceeds remaining budget
|
|
126
|
+
if (request.context.remainingBudgetUsd > 0 &&
|
|
127
|
+
estimated.actualUsd > request.context.remainingBudgetUsd * 0.95) {
|
|
128
|
+
return {
|
|
129
|
+
status: "failed",
|
|
130
|
+
summary: `Preflight: projected cost $${estimated.actualUsd.toFixed(4)} exceeds remaining budget $${request.context.remainingBudgetUsd.toFixed(4)}.`,
|
|
131
|
+
usage: normalizeUsage({
|
|
132
|
+
actualUsd: estimated.actualUsd,
|
|
133
|
+
estimatedUsd: estimated.actualUsd,
|
|
134
|
+
tokensIn: estimated.tokensIn,
|
|
135
|
+
tokensOut: estimated.tokensOut,
|
|
136
|
+
provenance: "estimated"
|
|
137
|
+
}),
|
|
138
|
+
verification: { passed: false, summary: "Stopped before execution: budget preflight failed." },
|
|
139
|
+
failure: { message: "budget_preflight_exceeded", classHint: "budget_pressure" }
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
// Call the OpenAI-compatible endpoint
|
|
143
|
+
const endpoint = `${options.baseUrl.replace(/\/$/, "")}/v1/chat/completions`;
|
|
144
|
+
let responseText = "";
|
|
145
|
+
let tokensIn = estimated.tokensIn;
|
|
146
|
+
let tokensOut = 0;
|
|
147
|
+
const controller = new AbortController();
|
|
148
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
149
|
+
try {
|
|
150
|
+
const headers = { "Content-Type": "application/json" };
|
|
151
|
+
if (options.apiKey)
|
|
152
|
+
headers["Authorization"] = `Bearer ${options.apiKey}`;
|
|
153
|
+
// OpenRouter requires a site URL header for attribution
|
|
154
|
+
if (options.baseUrl.includes("openrouter")) {
|
|
155
|
+
headers["HTTP-Referer"] = "https://martinloop.com";
|
|
156
|
+
headers["X-Title"] = "MartinLoop";
|
|
157
|
+
}
|
|
158
|
+
const res = await fetchFn(endpoint, {
|
|
159
|
+
method: "POST",
|
|
160
|
+
headers,
|
|
161
|
+
body: JSON.stringify({
|
|
162
|
+
model: options.model,
|
|
163
|
+
messages: [
|
|
164
|
+
{ role: "system", content: systemPrompt },
|
|
165
|
+
{ role: "user", content: prompt }
|
|
166
|
+
],
|
|
167
|
+
temperature: 0.2,
|
|
168
|
+
max_tokens: 8192
|
|
169
|
+
}),
|
|
170
|
+
signal: controller.signal
|
|
171
|
+
});
|
|
172
|
+
const body = (await res.json());
|
|
173
|
+
if (!res.ok || body.error) {
|
|
174
|
+
const errMsg = body.error?.message ?? `HTTP ${res.status}`;
|
|
175
|
+
return {
|
|
176
|
+
status: "failed",
|
|
177
|
+
summary: `${options.model} API error: ${errMsg}`,
|
|
178
|
+
usage: normalizeUsage({ actualUsd: 0, tokensIn: 0, tokensOut: 0, provenance: "unavailable" }),
|
|
179
|
+
verification: { passed: false, summary: "API call failed before verifier." },
|
|
180
|
+
failure: { message: errMsg, classHint: "infrastructure_error" }
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
responseText = body.choices?.[0]?.message?.content ?? "";
|
|
184
|
+
if (body.usage) {
|
|
185
|
+
tokensIn = body.usage.prompt_tokens ?? tokensIn;
|
|
186
|
+
tokensOut = body.usage.completion_tokens ?? 0;
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
tokensOut = Math.ceil(responseText.length / CHARS_PER_TOKEN);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
const isAbort = error instanceof Error && error.name === "AbortError";
|
|
194
|
+
const message = isAbort ? `${options.model} request timed out after ${timeoutMs}ms` : String(error);
|
|
195
|
+
return {
|
|
196
|
+
status: "failed",
|
|
197
|
+
summary: message,
|
|
198
|
+
usage: normalizeUsage({ actualUsd: 0, tokensIn: 0, tokensOut: 0, provenance: "unavailable" }),
|
|
199
|
+
verification: { passed: false, summary: isAbort ? "Request timed out." : "Network error." },
|
|
200
|
+
failure: { message, classHint: "infrastructure_error" }
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
finally {
|
|
204
|
+
clearTimeout(timer);
|
|
205
|
+
}
|
|
206
|
+
if (!responseText.trim()) {
|
|
207
|
+
return {
|
|
208
|
+
status: "failed",
|
|
209
|
+
summary: `${options.model} returned an empty response.`,
|
|
210
|
+
usage: normalizeUsage({ actualUsd: 0, tokensIn, tokensOut: 0, provenance: "actual" }),
|
|
211
|
+
verification: { passed: false, summary: "Empty response — nothing to verify." },
|
|
212
|
+
failure: { message: "empty_response" }
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
// Run verification
|
|
216
|
+
const verification = await runVerification(request.context.verificationPlan, workingDirectory, verifyTimeoutMs, request.context.verificationStack);
|
|
217
|
+
const execution = await readGitExecutionArtifacts(workingDirectory, 5_000);
|
|
218
|
+
const pricing = KNOWN_MODEL_PRICING[options.model] ?? {
|
|
219
|
+
inputPer1K: FALLBACK_INPUT_PER_1K,
|
|
220
|
+
outputPer1K: FALLBACK_OUTPUT_PER_1K
|
|
221
|
+
};
|
|
222
|
+
const actualUsd = (tokensIn / 1000) * pricing.inputPer1K + (tokensOut / 1000) * pricing.outputPer1K;
|
|
223
|
+
return {
|
|
224
|
+
status: verification.passed ? "completed" : "failed",
|
|
225
|
+
summary: verification.passed
|
|
226
|
+
? `${options.model} completed the task. Verifier passed.`
|
|
227
|
+
: `${options.model} completed but verifier failed: ${verification.summary}`,
|
|
228
|
+
usage: normalizeUsage({
|
|
229
|
+
actualUsd,
|
|
230
|
+
tokensIn,
|
|
231
|
+
tokensOut,
|
|
232
|
+
provenance: "actual"
|
|
233
|
+
}),
|
|
234
|
+
verification,
|
|
235
|
+
execution,
|
|
236
|
+
...(verification.passed ? {} : {
|
|
237
|
+
failure: { message: verification.summary }
|
|
238
|
+
})
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
type McpWorkflowStepName = "doctor" | "plan" | "preflight";
|
|
2
|
+
export interface RecordMcpWorkflowStepInput {
|
|
3
|
+
runsRoot: string;
|
|
4
|
+
step: McpWorkflowStepName;
|
|
5
|
+
workingDirectory: string;
|
|
6
|
+
objective?: string;
|
|
7
|
+
engine?: string;
|
|
8
|
+
verificationPlan?: string[];
|
|
9
|
+
}
|
|
10
|
+
export interface EvaluateMcpRunGateInput {
|
|
11
|
+
runsRoot: string;
|
|
12
|
+
workingDirectory: string;
|
|
13
|
+
objective: string;
|
|
14
|
+
engine?: string;
|
|
15
|
+
verificationPlan?: string[];
|
|
16
|
+
}
|
|
17
|
+
export interface McpRunGateResult {
|
|
18
|
+
allowed: boolean;
|
|
19
|
+
nextAction: string;
|
|
20
|
+
summary: string;
|
|
21
|
+
missingSteps: McpWorkflowStepName[];
|
|
22
|
+
}
|
|
23
|
+
export declare function recordMcpWorkflowStep(input: RecordMcpWorkflowStepInput): Promise<void>;
|
|
24
|
+
export declare function evaluateMcpRunGate(input: EvaluateMcpRunGateInput): Promise<McpRunGateResult>;
|
|
25
|
+
export {};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
const WORKFLOW_STATE_DIRECTORY = "_martin";
|
|
5
|
+
const WORKFLOW_STATE_FILENAME = "workflow-state.json";
|
|
6
|
+
const DOCTOR_TTL_MS = 24 * 60 * 60 * 1000;
|
|
7
|
+
const PLAN_TTL_MS = 24 * 60 * 60 * 1000;
|
|
8
|
+
const PREFLIGHT_TTL_MS = 6 * 60 * 60 * 1000;
|
|
9
|
+
export async function recordMcpWorkflowStep(input) {
|
|
10
|
+
const state = await readWorkflowState(input.runsRoot);
|
|
11
|
+
state.mcp ??= {};
|
|
12
|
+
state.mcp[input.step] = {
|
|
13
|
+
step: input.step,
|
|
14
|
+
recordedAt: new Date().toISOString(),
|
|
15
|
+
workingDirectory: normalizeWorkingDirectory(input.workingDirectory),
|
|
16
|
+
...(input.objective ? { objectiveKey: normalizeObjective(input.objective) } : {}),
|
|
17
|
+
...(input.engine ? { engine: input.engine } : {}),
|
|
18
|
+
...(input.verificationPlan ? { verificationPlanKey: hashVerificationPlan(input.verificationPlan) } : {})
|
|
19
|
+
};
|
|
20
|
+
await writeWorkflowState(input.runsRoot, state);
|
|
21
|
+
}
|
|
22
|
+
export async function evaluateMcpRunGate(input) {
|
|
23
|
+
const state = await readWorkflowState(input.runsRoot);
|
|
24
|
+
const mcpState = state.mcp ?? {};
|
|
25
|
+
const workingDirectory = normalizeWorkingDirectory(input.workingDirectory);
|
|
26
|
+
const objectiveKey = normalizeObjective(input.objective);
|
|
27
|
+
const engine = input.engine ?? "claude";
|
|
28
|
+
const verificationPlanKey = hashVerificationPlan(input.verificationPlan ?? []);
|
|
29
|
+
const missingSteps = [];
|
|
30
|
+
if (!isFresh(mcpState["doctor"], DOCTOR_TTL_MS, (receipt) => receipt.workingDirectory === workingDirectory)) {
|
|
31
|
+
missingSteps.push("doctor");
|
|
32
|
+
}
|
|
33
|
+
if (!isFresh(mcpState["plan"], PLAN_TTL_MS, (receipt) => receipt.workingDirectory === workingDirectory &&
|
|
34
|
+
receipt.objectiveKey === objectiveKey)) {
|
|
35
|
+
missingSteps.push("plan");
|
|
36
|
+
}
|
|
37
|
+
if (!isFresh(mcpState["preflight"], PREFLIGHT_TTL_MS, (receipt) => receipt.workingDirectory === workingDirectory &&
|
|
38
|
+
receipt.objectiveKey === objectiveKey &&
|
|
39
|
+
receipt.engine === engine &&
|
|
40
|
+
receipt.verificationPlanKey === verificationPlanKey)) {
|
|
41
|
+
missingSteps.push("preflight");
|
|
42
|
+
}
|
|
43
|
+
if (missingSteps.length === 0) {
|
|
44
|
+
return {
|
|
45
|
+
allowed: true,
|
|
46
|
+
nextAction: "martin_run",
|
|
47
|
+
summary: "Martin MCP governance receipts are present for this task.",
|
|
48
|
+
missingSteps
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
const nextAction = missingSteps[0] === "doctor"
|
|
52
|
+
? "Call martin_doctor for this workingDirectory before any real run."
|
|
53
|
+
: missingSteps[0] === "plan"
|
|
54
|
+
? "Call martin_plan with the exact objective before martin_run."
|
|
55
|
+
: "Call martin_preflight with the exact objective, verifier plan, and engine before martin_run.";
|
|
56
|
+
return {
|
|
57
|
+
allowed: false,
|
|
58
|
+
nextAction,
|
|
59
|
+
summary: `martin_run is blocked until Martin MCP receipts exist for ${missingSteps.join(", ")}.`,
|
|
60
|
+
missingSteps
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
async function readWorkflowState(runsRoot) {
|
|
64
|
+
const statePath = resolveWorkflowStatePath(runsRoot);
|
|
65
|
+
try {
|
|
66
|
+
const raw = await readFile(statePath, "utf8");
|
|
67
|
+
const parsed = JSON.parse(raw);
|
|
68
|
+
return parsed.version === 1 ? parsed : { version: 1 };
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return { version: 1 };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
async function writeWorkflowState(runsRoot, state) {
|
|
75
|
+
const statePath = resolveWorkflowStatePath(runsRoot);
|
|
76
|
+
await mkdir(join(resolve(runsRoot), WORKFLOW_STATE_DIRECTORY), { recursive: true });
|
|
77
|
+
await writeFile(statePath, JSON.stringify(state, null, 2), "utf8");
|
|
78
|
+
}
|
|
79
|
+
function resolveWorkflowStatePath(runsRoot) {
|
|
80
|
+
return join(resolve(runsRoot), WORKFLOW_STATE_DIRECTORY, WORKFLOW_STATE_FILENAME);
|
|
81
|
+
}
|
|
82
|
+
function isFresh(receipt, ttlMs, predicate) {
|
|
83
|
+
if (!receipt || !predicate(receipt)) {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
const recordedAt = Date.parse(receipt.recordedAt);
|
|
87
|
+
if (Number.isNaN(recordedAt)) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
return Date.now() - recordedAt <= ttlMs;
|
|
91
|
+
}
|
|
92
|
+
function normalizeWorkingDirectory(workingDirectory) {
|
|
93
|
+
const resolved = resolve(workingDirectory);
|
|
94
|
+
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
|
95
|
+
}
|
|
96
|
+
function normalizeObjective(objective) {
|
|
97
|
+
return objective.trim().replace(/\s+/gu, " ").toLowerCase();
|
|
98
|
+
}
|
|
99
|
+
function hashVerificationPlan(verificationPlan) {
|
|
100
|
+
const normalized = verificationPlan.map((step) => step.trim()).filter(Boolean);
|
|
101
|
+
return createHash("sha256").update(JSON.stringify(normalized)).digest("hex").slice(0, 12);
|
|
102
|
+
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@martinloop/mcp",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.7",
|
|
4
4
|
"mcpName": "io.github.Keesan12/martin-loop",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
7
7
|
"description": "Governed MCP server for AI coding agents with budgets, verifier gates, and inspectable runs.",
|
|
8
8
|
"license": "Apache-2.0",
|
|
9
|
-
"author": "
|
|
9
|
+
"author": "MartinLoop contributors",
|
|
10
10
|
"homepage": "https://martinloop.com/",
|
|
11
11
|
"repository": {
|
|
12
12
|
"type": "git",
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
"inspect:live": "node ./scripts/inspect-live.mjs"
|
|
62
62
|
},
|
|
63
63
|
"dependencies": {
|
|
64
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
64
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
65
65
|
"@open-policy-agent/opa-wasm": "^1.10.0",
|
|
66
66
|
"@opentelemetry/api-logs": "^0.214.0",
|
|
67
67
|
"@opentelemetry/exporter-logs-otlp-http": "^0.214.0",
|
package/server.json
CHANGED
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
"url": "https://github.com/Keesan12/martin-loop",
|
|
8
8
|
"source": "github"
|
|
9
9
|
},
|
|
10
|
-
"version": "0.2.
|
|
10
|
+
"version": "0.2.7",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"identifier": "@martinloop/mcp",
|
|
15
|
-
"version": "0.2.
|
|
15
|
+
"version": "0.2.7",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
}
|