@precode/mcp 0.1.0
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 +75 -0
- package/dist/index.js +325 -0
- package/dist/store.js +258 -0
- package/dist/telemetry.js +40 -0
- package/package.json +40 -0
package/README.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# @precode/mcp
|
|
2
|
+
|
|
3
|
+
Open, agent-agnostic MCP server that turns a spec into a **self-correcting, verified build**. Works with **any** spec (`SPEC.md`, `.specify`, `.spec-workflow`) and **best** with a PreCode [`.precode/`](https://precode.dev) package. MIT licensed, free, runs on your agent and your tokens.
|
|
4
|
+
|
|
5
|
+
It fixes the loop/goal failure other spec MCPs ship with:
|
|
6
|
+
|
|
7
|
+
- **Goal re-anchoring** — the agent re-reads the goal every phase, so long builds don't drift.
|
|
8
|
+
- **Hard definition-of-done** — a task is marked done **only when its checks actually pass**, never when the agent merely claims so.
|
|
9
|
+
- **Closed verify → fix → re-verify** — gaps come back with fixes and the loop will not advance past them.
|
|
10
|
+
- **Deterministic-first** — trusts the build/type-check/lint/test results your agent runs in its terminal.
|
|
11
|
+
|
|
12
|
+
At the end it writes an **`IMPLEMENTATION.md`** (what was built) and a **`TODO_FOR_YOU.md`** (what you still need to do — secrets, env, deploy).
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
Runs over stdio. Point any MCP host at it.
|
|
17
|
+
|
|
18
|
+
**Cursor** — `.cursor/mcp.json`:
|
|
19
|
+
|
|
20
|
+
```json
|
|
21
|
+
{
|
|
22
|
+
"mcpServers": {
|
|
23
|
+
"precode": { "command": "npx", "args": ["-y", "@precode/mcp"] }
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
**Claude Code:**
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
claude mcp add precode -- npx -y @precode/mcp
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
**Codex / Windsurf / VS Code** — add the same `command: npx`, `args: ["-y", "@precode/mcp"]` to their MCP config.
|
|
35
|
+
|
|
36
|
+
The server reads `.precode/` from the directory the host runs it in (`PRECODE_ROOT` overrides). Local stdio only — the build loop needs your filesystem.
|
|
37
|
+
|
|
38
|
+
## Telemetry
|
|
39
|
+
|
|
40
|
+
Telemetry is off unless `PRECODE_TELEMETRY_URL` is set. When enabled, the server sends privacy-safe beta events such as `mcp_next_task`, `mcp_verify_pass`, and `mcp_finalize`; it hashes the local root path and never sends spec contents, file contents, secrets, or task text. Set `PRECODE_TELEMETRY_DISABLED=1` to force-disable it.
|
|
41
|
+
|
|
42
|
+
## Use
|
|
43
|
+
|
|
44
|
+
1. Drop a PreCode export (or run `precode.adopt_spec` on your own `SPEC.md`).
|
|
45
|
+
2. Run the `precode_build` prompt, or just tell your agent: *"build this with the precode loop."*
|
|
46
|
+
|
|
47
|
+
## Local Verification
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npm run test:stdio
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The smoke test launches the built MCP server over stdio, verifies the tool list, confirms failed checks keep a task open, confirms passing checks mark it done, reads `precode://docs`, and writes the final `TODO_FOR_YOU.md`.
|
|
54
|
+
|
|
55
|
+
## Tools
|
|
56
|
+
|
|
57
|
+
| Tool | Does |
|
|
58
|
+
|------|------|
|
|
59
|
+
| `precode.get_goal` | Re-anchor on the goal + progress |
|
|
60
|
+
| `precode.next_phase` | Alias for the next phased build step |
|
|
61
|
+
| `precode.next_task` | Pull the next step + acceptance criteria |
|
|
62
|
+
| `precode.verify` | Report check results; marks done **only if they pass** |
|
|
63
|
+
| `precode.record_implementation` | Append to the implementation ledger |
|
|
64
|
+
| `precode.finalize` | Write `TODO_FOR_YOU.md` |
|
|
65
|
+
| `precode.adopt_spec` | Map a non-PreCode spec into `.precode/` |
|
|
66
|
+
|
|
67
|
+
## Resources
|
|
68
|
+
|
|
69
|
+
| Resource | Contains |
|
|
70
|
+
|----------|----------|
|
|
71
|
+
| `precode://goal` | Goal and task progress |
|
|
72
|
+
| `precode://docs` | Bundled `.precode/docs/*.md` specs |
|
|
73
|
+
| `precode://tasks` | Current phased task plan |
|
|
74
|
+
| `precode://acceptance` | Acceptance criteria and self-checks |
|
|
75
|
+
| `precode://progress` | Implementation ledger and human TODOs |
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { PrecodeStore, adoptSpec } from "./store.js";
|
|
6
|
+
import { recordTelemetry, taskTelemetry } from "./telemetry.js";
|
|
7
|
+
/**
|
|
8
|
+
* @precode/mcp — drives a phased build → recheck → fix loop over a `.precode/`
|
|
9
|
+
* package. Fixes the loop/goal failure other spec MCPs ship with:
|
|
10
|
+
* - goal re-anchoring every phase (get_goal / next_task echo the goal),
|
|
11
|
+
* - HARD definition-of-done: a task is only marked done when its checks pass,
|
|
12
|
+
* - closed verify→fix→re-verify (gaps return fixes, do not advance),
|
|
13
|
+
* - deterministic checks run by the agent; LLM-judgment is not trusted here.
|
|
14
|
+
*/
|
|
15
|
+
const ROOT = process.env.PRECODE_ROOT ?? process.cwd();
|
|
16
|
+
async function store() {
|
|
17
|
+
return PrecodeStore.locate(ROOT);
|
|
18
|
+
}
|
|
19
|
+
function text(s) {
|
|
20
|
+
return { content: [{ type: "text", text: s }] };
|
|
21
|
+
}
|
|
22
|
+
const NO_STORE = text("No `.precode/` package found in this directory. Either drop a PreCode export here, " +
|
|
23
|
+
"or call `precode.adopt_spec` to map a plain SPEC.md / .specify / .spec-workflow folder into one.");
|
|
24
|
+
const server = new McpServer({ name: "precode", version: "0.1.0" });
|
|
25
|
+
// --- Tool: get the goal (anti-drift anchor) -------------------------------
|
|
26
|
+
server.tool("precode.get_goal", "Re-read the project goal and progress. Call this at the start of every phase so the build does not drift from the spec.", {}, async () => {
|
|
27
|
+
const s = await store();
|
|
28
|
+
if (!s)
|
|
29
|
+
return NO_STORE;
|
|
30
|
+
await recordTelemetry({ eventName: "mcp_get_goal", root: ROOT });
|
|
31
|
+
return text(await s.goal());
|
|
32
|
+
});
|
|
33
|
+
// --- Tool: next task ------------------------------------------------------
|
|
34
|
+
server.tool("precode.next_task", "Pull the next unchecked build step plus the goal and acceptance criteria. Build ONLY this task, then call precode.verify. Do not skip ahead.", {}, async () => {
|
|
35
|
+
const s = await store();
|
|
36
|
+
if (!s)
|
|
37
|
+
return NO_STORE;
|
|
38
|
+
const task = await s.nextOpenTask();
|
|
39
|
+
await recordTelemetry({
|
|
40
|
+
eventName: "mcp_next_task",
|
|
41
|
+
root: ROOT,
|
|
42
|
+
task: taskTelemetry(task),
|
|
43
|
+
});
|
|
44
|
+
if (!task) {
|
|
45
|
+
return text("All tasks complete. Run precode.finalize to write the implementation ledger and TODO_FOR_YOU.md.");
|
|
46
|
+
}
|
|
47
|
+
const goal = await s.goal();
|
|
48
|
+
const acceptance = await s.acceptance();
|
|
49
|
+
return text([
|
|
50
|
+
"## GOAL (re-anchor before building)",
|
|
51
|
+
goal,
|
|
52
|
+
"",
|
|
53
|
+
`## NEXT TASK (#${task.index})`,
|
|
54
|
+
task.text,
|
|
55
|
+
"",
|
|
56
|
+
"Re-read the relevant doc in `.precode/docs/` before coding.",
|
|
57
|
+
"Build ONLY this task. Then run the checks and call precode.verify.",
|
|
58
|
+
"",
|
|
59
|
+
"## ACCEPTANCE CRITERIA",
|
|
60
|
+
acceptance,
|
|
61
|
+
].join("\n"));
|
|
62
|
+
});
|
|
63
|
+
server.tool("precode.next_phase", "Alias for precode.next_task. Pulls the next phased build step plus the goal and acceptance criteria.", {}, async () => {
|
|
64
|
+
const s = await store();
|
|
65
|
+
if (!s)
|
|
66
|
+
return NO_STORE;
|
|
67
|
+
const task = await s.nextOpenTask();
|
|
68
|
+
await recordTelemetry({
|
|
69
|
+
eventName: "mcp_next_task",
|
|
70
|
+
root: ROOT,
|
|
71
|
+
task: taskTelemetry(task),
|
|
72
|
+
});
|
|
73
|
+
if (!task) {
|
|
74
|
+
return text("All tasks complete. Run precode.finalize to write the implementation ledger and TODO_FOR_YOU.md.");
|
|
75
|
+
}
|
|
76
|
+
const goal = await s.goal();
|
|
77
|
+
const acceptance = await s.acceptance();
|
|
78
|
+
return text([
|
|
79
|
+
"## GOAL (re-anchor before building)",
|
|
80
|
+
goal,
|
|
81
|
+
"",
|
|
82
|
+
`## NEXT PHASE / TASK (#${task.index})`,
|
|
83
|
+
task.text,
|
|
84
|
+
"",
|
|
85
|
+
"Re-read the relevant doc in `.precode/docs/` before coding.",
|
|
86
|
+
"Build ONLY this task. Then run the checks and call precode.verify.",
|
|
87
|
+
"",
|
|
88
|
+
"## ACCEPTANCE CRITERIA",
|
|
89
|
+
acceptance,
|
|
90
|
+
].join("\n"));
|
|
91
|
+
});
|
|
92
|
+
// --- Tool: verify (the hard done-gate + fix loop) -------------------------
|
|
93
|
+
server.tool("precode.verify", "Report the results of the deterministic checks you RAN (build/type-check/lint/tests). The task is marked done ONLY if every reported check passed and at least one ran. Otherwise you get the gaps back and the task stays open — fix and call precode.verify again.", {
|
|
94
|
+
checkResults: z
|
|
95
|
+
.array(z.object({
|
|
96
|
+
name: z.string().describe("e.g. 'build', 'typecheck', 'lint', 'smoke test'"),
|
|
97
|
+
passed: z.boolean(),
|
|
98
|
+
detail: z.string().optional(),
|
|
99
|
+
}))
|
|
100
|
+
.describe("Results of checks you actually executed in the terminal."),
|
|
101
|
+
notes: z.string().optional(),
|
|
102
|
+
task: z.string().optional().describe("Optional host-side task identifier or title."),
|
|
103
|
+
}, async ({ checkResults, notes, task: taskLabel }) => {
|
|
104
|
+
const s = await store();
|
|
105
|
+
if (!s)
|
|
106
|
+
return NO_STORE;
|
|
107
|
+
const task = await s.nextOpenTask();
|
|
108
|
+
if (!task)
|
|
109
|
+
return text("No open task to verify. Run precode.finalize.");
|
|
110
|
+
if (checkResults.length === 0) {
|
|
111
|
+
return text(`HOLD. Task #${task.index} cannot be marked done — you reported no checks. ` +
|
|
112
|
+
"Run the acceptance checks (build, type-check, lint, smoke test) in your terminal, then report results.");
|
|
113
|
+
}
|
|
114
|
+
const failed = checkResults.filter((c) => !c.passed);
|
|
115
|
+
if (failed.length > 0) {
|
|
116
|
+
const failureLines = failed.map((c) => `${c.name}: FAILED${c.detail ? ` — ${c.detail}` : ""}`);
|
|
117
|
+
const retry = await s.recordVerificationFailure({
|
|
118
|
+
task,
|
|
119
|
+
failures: failureLines,
|
|
120
|
+
});
|
|
121
|
+
await recordTelemetry({
|
|
122
|
+
eventName: "mcp_verify_fail",
|
|
123
|
+
root: ROOT,
|
|
124
|
+
task: taskTelemetry(task),
|
|
125
|
+
metadata: {
|
|
126
|
+
checkCount: checkResults.length,
|
|
127
|
+
failedCount: failed.length,
|
|
128
|
+
attempts: retry.attempts,
|
|
129
|
+
escalated: retry.escalated,
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
const gaps = failed
|
|
133
|
+
.map((c) => `- ${c.name}: FAILED${c.detail ? ` — ${c.detail}` : ""}`)
|
|
134
|
+
.join("\n");
|
|
135
|
+
const fixes = failed
|
|
136
|
+
.map((c) => `- ${c.name}: ${fixHint(c.name)}`)
|
|
137
|
+
.join("\n");
|
|
138
|
+
return text([
|
|
139
|
+
`GATE: Task #${task.index} stays OPEN. ${failed.length} check(s) failed:`,
|
|
140
|
+
gaps,
|
|
141
|
+
"",
|
|
142
|
+
"Concrete next fixes:",
|
|
143
|
+
fixes,
|
|
144
|
+
"",
|
|
145
|
+
`Verify attempt ${retry.attempts}/3 for this task.`,
|
|
146
|
+
retry.escalated
|
|
147
|
+
? "Escalated honestly into .precode/progress/TODO_FOR_YOU.md because the retry bound was reached."
|
|
148
|
+
: "Fix these, re-run the checks, and call precode.verify again. Do not call precode.next_task until this passes.",
|
|
149
|
+
].join("\n"));
|
|
150
|
+
}
|
|
151
|
+
await s.markTaskDone(task.index);
|
|
152
|
+
await recordTelemetry({
|
|
153
|
+
eventName: "mcp_verify_pass",
|
|
154
|
+
root: ROOT,
|
|
155
|
+
task: taskTelemetry(task),
|
|
156
|
+
metadata: { checkCount: checkResults.length },
|
|
157
|
+
});
|
|
158
|
+
await s.appendImplementation([
|
|
159
|
+
`## Task #${task.index}: ${task.text}`,
|
|
160
|
+
taskLabel ? `Host task: ${taskLabel}` : "",
|
|
161
|
+
`Verified: ${checkResults.map((c) => `${c.name} ✓`).join(", ")}`,
|
|
162
|
+
notes ? `Notes: ${notes}` : "",
|
|
163
|
+
]
|
|
164
|
+
.filter(Boolean)
|
|
165
|
+
.join("\n"));
|
|
166
|
+
const next = await s.nextOpenTask();
|
|
167
|
+
return text(next
|
|
168
|
+
? `PASS. Task #${task.index} marked done. Call precode.next_task for the next step (#${next.index}).`
|
|
169
|
+
: `PASS. Task #${task.index} marked done. All tasks complete — call precode.finalize.`);
|
|
170
|
+
});
|
|
171
|
+
// --- Tool: record implementation (ledger) ---------------------------------
|
|
172
|
+
server.tool("precode.record_implementation", "Log what you built for a task and any undocumented decisions, into .precode/progress/IMPLEMENTATION.md.", {
|
|
173
|
+
summary: z.string(),
|
|
174
|
+
task: z.string().optional(),
|
|
175
|
+
files: z.array(z.string()).optional(),
|
|
176
|
+
decisions: z.array(z.string()).optional(),
|
|
177
|
+
}, async ({ summary, task, files, decisions }) => {
|
|
178
|
+
const s = await store();
|
|
179
|
+
if (!s)
|
|
180
|
+
return NO_STORE;
|
|
181
|
+
await recordTelemetry({
|
|
182
|
+
eventName: "mcp_record_implementation",
|
|
183
|
+
root: ROOT,
|
|
184
|
+
metadata: {
|
|
185
|
+
fileCount: files?.length ?? 0,
|
|
186
|
+
decisionCount: decisions?.length ?? 0,
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
await s.appendImplementation([
|
|
190
|
+
`### ${summary}`,
|
|
191
|
+
task ? `Task: ${task}` : "",
|
|
192
|
+
files?.length ? `Files: ${files.join(", ")}` : "",
|
|
193
|
+
decisions?.length ? `Decisions:\n${decisions.map((d) => `- ${d}`).join("\n")}` : "",
|
|
194
|
+
]
|
|
195
|
+
.filter(Boolean)
|
|
196
|
+
.join("\n"));
|
|
197
|
+
return text("Recorded to .precode/progress/IMPLEMENTATION.md.");
|
|
198
|
+
});
|
|
199
|
+
// --- Tool: finalize (handoff ledger) --------------------------------------
|
|
200
|
+
server.tool("precode.finalize", "Write the handoff: confirm all tasks done and record what the USER still must do (secrets, env, deploy, anything not auto-verifiable) into .precode/progress/TODO_FOR_YOU.md.", {
|
|
201
|
+
userTodos: z
|
|
202
|
+
.array(z.string())
|
|
203
|
+
.describe("Plain-language steps the human must do: API keys, env vars, deploy, manual checks."),
|
|
204
|
+
unresolved: z
|
|
205
|
+
.array(z.string())
|
|
206
|
+
.optional()
|
|
207
|
+
.describe("Anything the loop could NOT satisfy — be honest, do not hide gaps."),
|
|
208
|
+
}, async ({ userTodos, unresolved }) => {
|
|
209
|
+
const s = await store();
|
|
210
|
+
if (!s)
|
|
211
|
+
return NO_STORE;
|
|
212
|
+
const tasks = await s.tasks();
|
|
213
|
+
const open = tasks.filter((t) => !t.done);
|
|
214
|
+
await recordTelemetry({
|
|
215
|
+
eventName: "mcp_finalize",
|
|
216
|
+
root: ROOT,
|
|
217
|
+
metadata: {
|
|
218
|
+
taskCount: tasks.length,
|
|
219
|
+
openTaskCount: open.length,
|
|
220
|
+
userTodoCount: userTodos.length,
|
|
221
|
+
unresolvedCount: unresolved?.length ?? 0,
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
const body = [
|
|
225
|
+
"# What you still need to do",
|
|
226
|
+
"",
|
|
227
|
+
"Generated by the PreCode MCP after the build+verify loop.",
|
|
228
|
+
"",
|
|
229
|
+
"## Your steps",
|
|
230
|
+
...(userTodos.length ? userTodos.map((t) => `- [ ] ${t}`) : ["- [ ] (none reported)"]),
|
|
231
|
+
"",
|
|
232
|
+
"## Not yet satisfied by the build",
|
|
233
|
+
...(unresolved?.length
|
|
234
|
+
? unresolved.map((u) => `- ${u}`)
|
|
235
|
+
: open.length
|
|
236
|
+
? open.map((t) => `- Task #${t.index} still open: ${t.text}`)
|
|
237
|
+
: ["- Nothing outstanding."]),
|
|
238
|
+
"",
|
|
239
|
+
].join("\n");
|
|
240
|
+
await s.writeTodo(body);
|
|
241
|
+
return text(`Finalized. ${tasks.length - open.length}/${tasks.length} tasks done. ` +
|
|
242
|
+
`Wrote .precode/progress/TODO_FOR_YOU.md and IMPLEMENTATION.md. ` +
|
|
243
|
+
(open.length ? `WARNING: ${open.length} task(s) still open — surfaced honestly in the TODO.` : "All tasks verified."));
|
|
244
|
+
});
|
|
245
|
+
// --- Tool: adopt any spec -------------------------------------------------
|
|
246
|
+
server.tool("precode.adopt_spec", "If there is no .precode/ here, map a plain SPEC.md / .specify / .spec-workflow / docs folder into a minimal .precode/ package so this loop works with ANY spec.", { specPath: z.string().optional().describe("Optional path hint to the spec file or folder.") }, async ({ specPath }) => {
|
|
247
|
+
const existing = await store();
|
|
248
|
+
if (existing)
|
|
249
|
+
return text("A .precode/ package already exists here. Call precode.next_task.");
|
|
250
|
+
const result = await adoptSpec(ROOT, specPath);
|
|
251
|
+
await recordTelemetry({
|
|
252
|
+
eventName: "mcp_adopt_spec",
|
|
253
|
+
root: ROOT,
|
|
254
|
+
metadata: { ok: result.ok, hasSpecPath: !!specPath },
|
|
255
|
+
});
|
|
256
|
+
return text(result.ok
|
|
257
|
+
? `Adopted spec into .precode/. Call precode.next_task to start the build+verify loop.`
|
|
258
|
+
: `Could not adopt a spec: ${result.error}`);
|
|
259
|
+
});
|
|
260
|
+
// --- Resources: goal + acceptance (re-readable each phase) ----------------
|
|
261
|
+
server.resource("precode-goal", "precode://goal", async (uri) => {
|
|
262
|
+
const s = await store();
|
|
263
|
+
return { contents: [{ uri: uri.href, text: s ? await s.goal() : "No .precode/ package found." }] };
|
|
264
|
+
});
|
|
265
|
+
server.resource("precode-acceptance", "precode://acceptance", async (uri) => {
|
|
266
|
+
const s = await store();
|
|
267
|
+
return { contents: [{ uri: uri.href, text: s ? await s.acceptance() : "No .precode/ package found." }] };
|
|
268
|
+
});
|
|
269
|
+
server.resource("precode-docs", "precode://docs", async (uri) => {
|
|
270
|
+
const s = await store();
|
|
271
|
+
return { contents: [{ uri: uri.href, text: s ? await s.docsBundle() : "No .precode/ package found." }] };
|
|
272
|
+
});
|
|
273
|
+
server.resource("precode-tasks", "precode://tasks", async (uri) => {
|
|
274
|
+
const s = await store();
|
|
275
|
+
return { contents: [{ uri: uri.href, text: s ? await s.tasksRaw() : "No .precode/ package found." }] };
|
|
276
|
+
});
|
|
277
|
+
server.resource("precode-progress", "precode://progress", async (uri) => {
|
|
278
|
+
const s = await store();
|
|
279
|
+
return { contents: [{ uri: uri.href, text: s ? await s.progressBundle() : "No .precode/ package found." }] };
|
|
280
|
+
});
|
|
281
|
+
// --- Prompt: kick off the whole loop --------------------------------------
|
|
282
|
+
server.prompt("precode_build", "Start the PreCode build+verify loop for the spec in this repo.", () => ({
|
|
283
|
+
messages: [
|
|
284
|
+
{
|
|
285
|
+
role: "user",
|
|
286
|
+
content: {
|
|
287
|
+
type: "text",
|
|
288
|
+
text: "Build this project from its spec using the PreCode loop. Steps, repeated until done:\n" +
|
|
289
|
+
"1. Call precode.get_goal to anchor on the goal.\n" +
|
|
290
|
+
"2. Call precode.next_task. Re-read the cited doc. Build ONLY that task.\n" +
|
|
291
|
+
"3. Run the deterministic checks (build, type-check, lint, smoke test) in the terminal.\n" +
|
|
292
|
+
"4. Call precode.verify with the real results. If it stays OPEN, fix and re-verify — do NOT skip ahead.\n" +
|
|
293
|
+
"5. Call precode.record_implementation.\n" +
|
|
294
|
+
"When precode.next_task says all done, call precode.finalize with the user's remaining steps.\n" +
|
|
295
|
+
"If there is no .precode/ package, call precode.adopt_spec first.",
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
],
|
|
299
|
+
}));
|
|
300
|
+
async function main() {
|
|
301
|
+
const transport = new StdioServerTransport();
|
|
302
|
+
await server.connect(transport);
|
|
303
|
+
// stderr only — stdout is the MCP channel.
|
|
304
|
+
console.error(`[precode-mcp] ready. root=${ROOT}`);
|
|
305
|
+
}
|
|
306
|
+
main().catch((err) => {
|
|
307
|
+
console.error("[precode-mcp] fatal:", err);
|
|
308
|
+
process.exit(1);
|
|
309
|
+
});
|
|
310
|
+
function fixHint(checkName) {
|
|
311
|
+
const lower = checkName.toLowerCase();
|
|
312
|
+
if (lower.includes("type") || lower.includes("tsc")) {
|
|
313
|
+
return "open the TypeScript diagnostics, fix the reported type errors, then rerun the same type-check command.";
|
|
314
|
+
}
|
|
315
|
+
if (lower.includes("lint") || lower.includes("eslint")) {
|
|
316
|
+
return "fix the reported lint violations without changing spec scope, then rerun lint.";
|
|
317
|
+
}
|
|
318
|
+
if (lower.includes("build")) {
|
|
319
|
+
return "inspect the build error, fix the first failing route/module/env issue, then rerun the production build.";
|
|
320
|
+
}
|
|
321
|
+
if (lower.includes("test") || lower.includes("smoke") || lower.includes("e2e")) {
|
|
322
|
+
return "reproduce the failing flow, align behavior to the acceptance criteria, then rerun the test.";
|
|
323
|
+
}
|
|
324
|
+
return "inspect the failing command output, make the smallest spec-aligned fix, then rerun this exact check.";
|
|
325
|
+
}
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const TASK_RE = /^(\s*)-\s*\[( |x|X)\]\s+(.*\S)\s*$/;
|
|
4
|
+
const MAX_VERIFY_FAILURES = 3;
|
|
5
|
+
export class PrecodeStore {
|
|
6
|
+
dir;
|
|
7
|
+
constructor(dir) {
|
|
8
|
+
this.dir = dir;
|
|
9
|
+
}
|
|
10
|
+
/** Absolute path to the `.precode/` directory. */
|
|
11
|
+
get root() {
|
|
12
|
+
return this.dir;
|
|
13
|
+
}
|
|
14
|
+
static async locate(searchRoot) {
|
|
15
|
+
const candidate = path.join(searchRoot, ".precode");
|
|
16
|
+
try {
|
|
17
|
+
const stat = await fs.stat(candidate);
|
|
18
|
+
if (stat.isDirectory())
|
|
19
|
+
return new PrecodeStore(candidate);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
/* not found */
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
file(rel) {
|
|
27
|
+
return path.join(this.dir, rel);
|
|
28
|
+
}
|
|
29
|
+
async read(rel) {
|
|
30
|
+
try {
|
|
31
|
+
return await fs.readFile(this.file(rel), "utf8");
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
async manifest() {
|
|
38
|
+
const raw = await this.read("manifest.json");
|
|
39
|
+
if (!raw)
|
|
40
|
+
return null;
|
|
41
|
+
try {
|
|
42
|
+
return JSON.parse(raw);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async acceptance() {
|
|
49
|
+
return (await this.read("acceptance.md")) ?? "(no acceptance.md found)";
|
|
50
|
+
}
|
|
51
|
+
async tasksRaw() {
|
|
52
|
+
return (await this.read("tasks.md")) ?? "";
|
|
53
|
+
}
|
|
54
|
+
async tasks() {
|
|
55
|
+
const raw = await this.tasksRaw();
|
|
56
|
+
const tasks = [];
|
|
57
|
+
let index = 0;
|
|
58
|
+
for (const line of raw.split("\n")) {
|
|
59
|
+
const m = TASK_RE.exec(line);
|
|
60
|
+
if (m) {
|
|
61
|
+
tasks.push({ index, text: m[3], done: m[2].toLowerCase() === "x" });
|
|
62
|
+
index += 1;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return tasks;
|
|
66
|
+
}
|
|
67
|
+
async nextOpenTask() {
|
|
68
|
+
return (await this.tasks()).find((t) => !t.done) ?? null;
|
|
69
|
+
}
|
|
70
|
+
/** Hard done-gate: only flips a task to [x]. Caller must verify checks first. */
|
|
71
|
+
async markTaskDone(taskIndex) {
|
|
72
|
+
const raw = await this.tasksRaw();
|
|
73
|
+
const lines = raw.split("\n");
|
|
74
|
+
let seen = -1;
|
|
75
|
+
for (let i = 0; i < lines.length; i++) {
|
|
76
|
+
const m = TASK_RE.exec(lines[i]);
|
|
77
|
+
if (!m)
|
|
78
|
+
continue;
|
|
79
|
+
seen += 1;
|
|
80
|
+
if (seen === taskIndex) {
|
|
81
|
+
lines[i] = `${m[1]}- [x] ${m[3]}`;
|
|
82
|
+
await fs.writeFile(this.file("tasks.md"), lines.join("\n"), "utf8");
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
/** Project + goal anchor re-read each phase to fight context drift. */
|
|
89
|
+
async goal() {
|
|
90
|
+
const manifest = await this.manifest();
|
|
91
|
+
const project = manifest?.project ?? {};
|
|
92
|
+
const tasks = await this.tasks();
|
|
93
|
+
const done = tasks.filter((t) => t.done).length;
|
|
94
|
+
return [
|
|
95
|
+
`Project: ${project.name ?? "(unknown)"} (${project.appType ?? "app"})`,
|
|
96
|
+
`Progress: ${done}/${tasks.length} tasks complete.`,
|
|
97
|
+
"Build strictly to the spec in `.precode/docs/`. Do not invent scope. Re-read the relevant doc before each task.",
|
|
98
|
+
].join("\n");
|
|
99
|
+
}
|
|
100
|
+
async appendImplementation(entry) {
|
|
101
|
+
await fs.mkdir(this.file("progress"), { recursive: true });
|
|
102
|
+
const cur = (await this.read("progress/IMPLEMENTATION.md")) ?? "# Implementation log\n";
|
|
103
|
+
await fs.writeFile(this.file("progress/IMPLEMENTATION.md"), `${cur.trimEnd()}\n\n${entry}\n`, "utf8");
|
|
104
|
+
}
|
|
105
|
+
async recordVerificationFailure(params) {
|
|
106
|
+
await fs.mkdir(this.file("progress"), { recursive: true });
|
|
107
|
+
const rel = "progress/verification-failures.json";
|
|
108
|
+
const raw = await this.read(rel);
|
|
109
|
+
let counts = {};
|
|
110
|
+
if (raw) {
|
|
111
|
+
try {
|
|
112
|
+
counts = JSON.parse(raw);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
counts = {};
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const key = String(params.task.index);
|
|
119
|
+
const attempts = (counts[key] ?? 0) + 1;
|
|
120
|
+
counts[key] = attempts;
|
|
121
|
+
await fs.writeFile(this.file(rel), JSON.stringify(counts, null, 2), "utf8");
|
|
122
|
+
if (attempts < MAX_VERIFY_FAILURES) {
|
|
123
|
+
return { attempts, escalated: false };
|
|
124
|
+
}
|
|
125
|
+
const cur = (await this.read("progress/TODO_FOR_YOU.md")) ??
|
|
126
|
+
"# TODO for you\n\nNo manual follow-up has been recorded yet.\n";
|
|
127
|
+
const entry = [
|
|
128
|
+
`## Task #${params.task.index} needs attention`,
|
|
129
|
+
"",
|
|
130
|
+
`The MCP verify gate has failed ${attempts} time(s) for this task: ${params.task.text}`,
|
|
131
|
+
"",
|
|
132
|
+
"Failed checks:",
|
|
133
|
+
...params.failures.map((failure) => `- ${failure}`),
|
|
134
|
+
"",
|
|
135
|
+
].join("\n");
|
|
136
|
+
await fs.writeFile(this.file("progress/TODO_FOR_YOU.md"), `${cur.trimEnd()}\n\n${entry}`, "utf8");
|
|
137
|
+
return { attempts, escalated: true };
|
|
138
|
+
}
|
|
139
|
+
async writeTodo(content) {
|
|
140
|
+
await fs.mkdir(this.file("progress"), { recursive: true });
|
|
141
|
+
await fs.writeFile(this.file("progress/TODO_FOR_YOU.md"), content, "utf8");
|
|
142
|
+
}
|
|
143
|
+
async listDocFiles() {
|
|
144
|
+
try {
|
|
145
|
+
const files = await fs.readdir(this.file("docs"));
|
|
146
|
+
return files.filter((f) => f.endsWith(".md")).map((f) => `docs/${f}`);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return [];
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
async readRel(rel) {
|
|
153
|
+
return this.read(rel);
|
|
154
|
+
}
|
|
155
|
+
async docsBundle() {
|
|
156
|
+
const files = await this.listDocFiles();
|
|
157
|
+
if (!files.length) {
|
|
158
|
+
return "(no docs found in .precode/docs)";
|
|
159
|
+
}
|
|
160
|
+
const parts = [];
|
|
161
|
+
for (const file of files) {
|
|
162
|
+
const body = await this.read(file);
|
|
163
|
+
parts.push(`# ${file}`, "", body ?? "(unreadable)", "");
|
|
164
|
+
}
|
|
165
|
+
return parts.join("\n");
|
|
166
|
+
}
|
|
167
|
+
async progressBundle() {
|
|
168
|
+
const implementation = (await this.read("progress/IMPLEMENTATION.md")) ??
|
|
169
|
+
"# Implementation ledger\n\nNo implementation has been recorded yet.";
|
|
170
|
+
const todo = (await this.read("progress/TODO_FOR_YOU.md")) ??
|
|
171
|
+
"# TODO for you\n\nNo manual follow-up has been recorded yet.";
|
|
172
|
+
return [implementation, "", "---", "", todo].join("\n");
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Adopt any spec into a minimal `.precode/` so non-PreCode specs get the loop.
|
|
177
|
+
* Looks for SPEC.md, a .specify/ or .spec-workflow/ folder, or a docs folder.
|
|
178
|
+
*/
|
|
179
|
+
export async function adoptSpec(searchRoot, specPathHint) {
|
|
180
|
+
const dir = path.join(searchRoot, ".precode");
|
|
181
|
+
const candidates = [
|
|
182
|
+
specPathHint,
|
|
183
|
+
"SPEC.md",
|
|
184
|
+
"spec.md",
|
|
185
|
+
".specify",
|
|
186
|
+
".spec-workflow/specs",
|
|
187
|
+
"docs",
|
|
188
|
+
].filter(Boolean);
|
|
189
|
+
let specBody = "";
|
|
190
|
+
let found = "";
|
|
191
|
+
for (const c of candidates) {
|
|
192
|
+
const abs = path.join(searchRoot, c);
|
|
193
|
+
try {
|
|
194
|
+
const stat = await fs.stat(abs);
|
|
195
|
+
if (stat.isFile()) {
|
|
196
|
+
specBody = await fs.readFile(abs, "utf8");
|
|
197
|
+
found = c;
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
if (stat.isDirectory()) {
|
|
201
|
+
const files = (await fs.readdir(abs)).filter((f) => f.endsWith(".md"));
|
|
202
|
+
if (files.length) {
|
|
203
|
+
specBody = (await Promise.all(files.map((f) => fs.readFile(path.join(abs, f), "utf8")))).join("\n\n");
|
|
204
|
+
found = c;
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
/* keep looking */
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (!specBody) {
|
|
214
|
+
return {
|
|
215
|
+
ok: false,
|
|
216
|
+
error: "No spec found. Provide a SPEC.md, a .specify/.spec-workflow folder, or a docs/ folder of markdown.",
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
// Derive tasks from markdown headings / existing checkboxes.
|
|
220
|
+
const tasks = [];
|
|
221
|
+
for (const line of specBody.split("\n")) {
|
|
222
|
+
const cb = TASK_RE.exec(line);
|
|
223
|
+
if (cb) {
|
|
224
|
+
tasks.push(`- [ ] ${cb[3]}`);
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
const h = /^#{2,3}\s+(.*\S)\s*$/.exec(line);
|
|
228
|
+
if (h && tasks.length < 40)
|
|
229
|
+
tasks.push(`- [ ] Implement: ${h[1]}`);
|
|
230
|
+
}
|
|
231
|
+
if (!tasks.length)
|
|
232
|
+
tasks.push("- [ ] Implement the full spec, then verify.");
|
|
233
|
+
await fs.mkdir(path.join(dir, "progress"), { recursive: true });
|
|
234
|
+
await fs.writeFile(path.join(dir, "docs", "SPEC.md"), specBody, "utf8").catch(async () => {
|
|
235
|
+
await fs.mkdir(path.join(dir, "docs"), { recursive: true });
|
|
236
|
+
await fs.writeFile(path.join(dir, "docs", "SPEC.md"), specBody, "utf8");
|
|
237
|
+
});
|
|
238
|
+
await fs.writeFile(path.join(dir, "tasks.md"), `# Tasks (adopted from ${found})\n\n${tasks.join("\n")}\n`, "utf8");
|
|
239
|
+
await fs.writeFile(path.join(dir, "acceptance.md"), [
|
|
240
|
+
"# Acceptance criteria and self-checks",
|
|
241
|
+
"",
|
|
242
|
+
"Adopted spec — outcome checks. Mark non-automatable items `needs manual verification`.",
|
|
243
|
+
"",
|
|
244
|
+
"## Runnable checks",
|
|
245
|
+
"- [ ] Dependencies install.",
|
|
246
|
+
"- [ ] Type-check passes.",
|
|
247
|
+
"- [ ] Lint passes.",
|
|
248
|
+
"- [ ] Production build succeeds.",
|
|
249
|
+
"- [ ] Primary user flow works end to end.",
|
|
250
|
+
"",
|
|
251
|
+
"## Manual verification",
|
|
252
|
+
"- [ ] Behavior matches `docs/SPEC.md`.",
|
|
253
|
+
"- [ ] Secrets, deploy env, and third-party integrations verified in real accounts.",
|
|
254
|
+
"",
|
|
255
|
+
].join("\n"), "utf8");
|
|
256
|
+
await fs.writeFile(path.join(dir, "manifest.json"), JSON.stringify({ project: { name: "Adopted spec", appType: "app" }, adoptedFrom: found }, null, 2), "utf8");
|
|
257
|
+
return { ok: true, dir };
|
|
258
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
function telemetryEnabled() {
|
|
3
|
+
if (process.env.PRECODE_TELEMETRY_DISABLED === "1") {
|
|
4
|
+
return false;
|
|
5
|
+
}
|
|
6
|
+
return !!process.env.PRECODE_TELEMETRY_URL;
|
|
7
|
+
}
|
|
8
|
+
export async function recordTelemetry(payload) {
|
|
9
|
+
if (!telemetryEnabled()) {
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
try {
|
|
13
|
+
await fetch(process.env.PRECODE_TELEMETRY_URL, {
|
|
14
|
+
method: "POST",
|
|
15
|
+
headers: { "Content-Type": "application/json" },
|
|
16
|
+
body: JSON.stringify({
|
|
17
|
+
...payload,
|
|
18
|
+
root: undefined,
|
|
19
|
+
rootHash: hashRoot(payload.root),
|
|
20
|
+
createdAt: new Date().toISOString(),
|
|
21
|
+
}),
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
console.error("[precode-mcp] telemetry failed:", e);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export function taskTelemetry(task) {
|
|
29
|
+
if (!task) {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
index: task.index,
|
|
34
|
+
done: task.done,
|
|
35
|
+
textLength: task.text.length,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function hashRoot(root) {
|
|
39
|
+
return createHash("sha256").update(root).digest("hex").slice(0, 16);
|
|
40
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@precode/mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Open, agent-agnostic MCP server that turns a spec (any SPEC.md, best with a PreCode .precode/ package) into a self-correcting, verified build. Drives a phased build → recheck → fix loop with hard definition-of-done gates and an implemented-vs-todo ledger.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"precode-mcp": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc",
|
|
16
|
+
"dev": "tsc --watch",
|
|
17
|
+
"start": "node dist/index.js",
|
|
18
|
+
"test:stdio": "npm run build && node scripts/stdio-smoke.mjs",
|
|
19
|
+
"typecheck": "tsc --noEmit"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"mcp",
|
|
23
|
+
"model-context-protocol",
|
|
24
|
+
"spec-driven-development",
|
|
25
|
+
"precode",
|
|
26
|
+
"ai-agents",
|
|
27
|
+
"verification"
|
|
28
|
+
],
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=18"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
34
|
+
"zod": "^3.23.0"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"typescript": "^5.6.0",
|
|
38
|
+
"@types/node": "^22.0.0"
|
|
39
|
+
}
|
|
40
|
+
}
|