Sheet 05MCP tool surfaceScale: NTS

Eleven tools over stdio

“MCP-first” is the claim in the first paragraph of the README, and the server is the part of this repository that most clearly delivers on it. It is 221 lines: two response helpers, one parser, eleven registerTool calls, and a stdio transport. There is no state in the server — every tool delegates to a single module-level DaedalusCore.

The whole server, structurally

packages/mcp-server/src/index.ts · lines 41–57, 215–221verbatim, abridged
41export async function startMcpServer(): Promise<void> {
42  const server = new McpServer({
43    name: "daedalus",
44    version: "0.1.0"
45  });
46
47  server.registerTool(
48    "daedalus_create_goal_contract",
49    {
50      title: "Create goal contract",
51      description: "Compile a vague user goal into a structured Daedalus goal contract.",
52      inputSchema: {
53        goal: z.string().min(1)
54      }
55    },
56    async ({ goal }) => textResponse(core.createGoalContract(goal))
57  );

215  const transport = new StdioServerTransport();
216  await server.connect(transport);
217}
218
219if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
220  await startMcpServer();
221}

The guard at line 219 lets the same module serve as both a library and an executable: the CLI imports startMcpServer and calls it from the mcp subcommand, while node dist/packages/mcp-server/src/index.js self-starts. Every tool result goes through one helper that serialises to a single text content block:

packages/mcp-server/src/index.ts · lines 10–19verbatim
10function textResponse(value: unknown) {
11  return {
12    content: [
13      {
14        type: "text" as const,
15        text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
16      }
17    ]
18  };
19}

No structured content, no output schemas, no resource links. A calling agent receives pretty-printed JSON as text and is expected to read it — or, for daedalus_propose_next_task, a plain English instruction. That is a deliberate simplification, and it is the reason the goal contract has to travel back into the server as a JSON string on three of the eleven tools.

Fig. 5.1 Tool surface — plan view

Plan of the eleven MCP tools, their input schemas, and the core methods they call Eleven tool blocks in a left column, each showing its zod input schema, connect rightward to the DaedalusCore methods they invoke. Three tools take a goalContractJson string and route through a shared parseGoalContract helper that can return an error response. Five tools take a runId string and reach the StateStore, which reads and writes run.json. Two tools take only a goal string and are pure. One tool, list adapters, takes an empty schema and returns the static adapter array. A right-hand column groups the tools by side effect: pure, disk read, and disk write. Only four of the eleven tools mutate state. TOOL · inputSchema (zod) CORE METHOD EFFECT daedalus_create_goal_contract goal: z.string().min(1) daedalus_plan_milestones goalContractJson: z.string().min(1) daedalus_select_evaluators goalContractJson: z.string().min(1) daedalus_propose_missing_evaluators goalContractJson: z.string().min(1) daedalus_list_adapters {} — empty schema daedalus_create_run goal: z.string().min(1) daedalus_report_status runId: z.string().min(1) daedalus_propose_next_task runId: z.string().min(1) daedalus_freeze_evals runId · notes?: z.array(z.string()) daedalus_open_attempt runId · milestoneId?: z.string() daedalus_score_attempt runId · attemptId? · milestoneId? proofScores: z.record(z.string(), z.number().min(0).max(1)) failures?: z.array(z.string()) · noRegressions?: z.boolean() parseGoalContract core.createGoalContract core.planMilestones core.selectEvaluators core.proposeMissingEvaluators core.listAdapters core.createRun core.loadRun core.loadRun then core.proposeNextTask core.freezeEvals core.openAttempt core.scoreRunAttempt PURE no filesystem access 5 tools deterministic apart from nanoid ids and timestamps DISK READ loadRun -> JSON.parse 2 tools throws ENOENT if absent DISK WRITE saveRun rewrites the entire run.json 4 tools create_run · freeze_evals open_attempt · score_attempt no lock, last write wins 1 Only three tools can return isError. parseGoalContract throws on malformed JSON or a contract missing id, goal, domain or a requiredProofs array; the three goalContractJson tools catch it and return an error content block. 2 Every other tool lets exceptions propagate — an unknown runId surfaces as a transport-level failure, not a tool error.
Fig. 5.1 — Five of eleven tools are pure functions of their arguments and could be called speculatively with no consequence. The four write tools are the entire mutating surface of the system. daedalus_score_attempt is the only tool whose schema is more than two fields, and the only one that accepts a numeric payload.

Tool reference

Descriptions are quoted verbatim from the registerTool metadata.

Registered tools — packages/mcp-server/src/index.ts:47–213
Tool name Title & description Input Line
daedalus_create_goal_contract Create goal contract
“Compile a vague user goal into a structured Daedalus goal contract.”
goal47
daedalus_plan_milestones Plan milestones
“Create a milestone DAG for a goal contract.”
goalContractJson59
daedalus_select_evaluators Select evaluators
“Select evaluator packs that can prove a goal contract.”
goalContractJson77
daedalus_create_run Create run
“Create and persist a Daedalus run from a user goal.”
goal95
daedalus_report_status Report status
“Load and return a persisted Daedalus run.”
runId107
daedalus_score_attempt Score attempt
“Record a scored attempt for a run and decide keep/revert/review.”
runId, attemptId?, milestoneId?, proofScores, failures?, noRegressions?119
daedalus_propose_next_task Propose next task
“Return the next milestone task prompt for a builder agent.”
runId146
daedalus_list_adapters List adapters
“List known external tool adapters and their intended role.”
none161
daedalus_freeze_evals Freeze evals
“Snapshot the selected evaluator manifest for a run before builder optimization.”
runId, notes?171
daedalus_open_attempt Open attempt
“Open a new attempt record for a run and optional milestone.”
runId, milestoneId?184
daedalus_propose_missing_evaluators Propose missing evaluators
“Create guarded evaluator proposals for proofs not covered by the registry.”
goalContractJson197

The proofScores schema is z.record(z.string(), z.number().min(0).max(1)) — an open string map, not z.record(z.enum([...proofs]), …). A caller may submit {"looks_good": 1} and the server will accept it, cast it to Partial<Record<Proof, number>> at index.ts:139, and the scorer will silently ignore it because it iterates the goal’s required proofs rather than the submitted keys. The Proof union is enforced by the compiler on the way out and not on the way in.

Notably absent: there is no daedalus_run_evals, no daedalus_validate_evaluator, and no tool that lists existing runs. The first of these is item 2 on the outstanding-work list in RESEARCH.md:311 — “Implement real run_evals execution instead of scoring by submitted proof scores.”

Fig. 5.2 Sequence — one milestone, proposed to accepted

Sequence diagram of an agent driving one milestone through Daedalus over MCP Four vertical lifelines: the MCP host agent, the Daedalus MCP server, DaedalusCore, and the run.json file on disk. The agent calls create_run, which compiles the goal and writes the file; then freeze_evals, which hashes the manifest and rewrites the file; then propose_next_task, which reads the file and returns an English instruction. The agent then leaves the diagram to edit the repository and run evaluators — drawn as a shaded region outside the Daedalus lifelines, labelled Daedalus is blind here. It returns with open_attempt and score_attempt, supplying proof scores it measured itself. The core computes a mean, compares it to the threshold, marks the milestone passed and rewrites the file. A closing note records that nothing prevents the agent from reporting scores it did not measure. MCP host agent daedalus MCP server DaedalusCore runs/<id>/run.json 1 daedalus_create_run { goal: "build me Doom" } core.createRun(goalText) saveRun — mkdir -p, writeFile RunState as pretty-printed JSON text 2 daedalus_freeze_evals { runId, notes: ["pre-build"] } loadRun · sort ids · stableHash · phase = "evals_frozen" saveRun — whole document rewritten FrozenEvalSnapshot { publicManifestHash: "b5f2049e" } 3 daedalus_propose_next_task { runId } loadRun · find first milestone with status !== "passed" "Implement milestone m0: Project boots. … Required proofs: boots, builds." 4 THE AGENT LEAVES — Daedalus is blind for this entire span edits the repository · installs dependencies · starts a dev server · drives Playwright · takes screenshots runs the build and test commands · decides for itself whether "boots" and "builds" are satisfied No tool is called during this span. No trace is recorded. No artifact path is registered with the run. The only channel back into the control plane is the proofScores object in step 6. 5 daedalus_open_attempt { runId, milestoneId: "m0" } push AttemptRecord · phase = "attempt_opened" · status = "running" · saveRun 6 daedalus_score_attempt { runId, attemptId, milestoneId: "m0", proofScores: { boots: 1, builds: 1 } } narrow goal to m0.requiredProofs · mean = 1.0 ≥ 0.85 · decision "keep" m0.status = "passed" · phase = "accepted" · saveRun AttemptScore { score: 1, gatesPassed: true, decision: "keep" } Steps 5 and 6 are the same agent that performed step 4. templates/AGENTS.md asks it to separate roles; the protocol does not require it.
Fig. 5.2 — The hatched band is the honest centre of the current design. Daedalus holds the contract, the frozen manifest and the ledger, but the measurement itself happens off-diagram and arrives as a self-report. docs/ARCHITECTURE.md:114–119 states the intended rule — “Artifact-level evidence beats trace-level evidence” — and the protocol as built cannot yet tell the two apart.

Connecting a host

docs/MCP_USAGE.md gives a host configuration for the compiled server. The cwd matters: without it, or without DAEDALUS_HOME, runs land wherever the host process started.

docs/MCP_USAGE.md — host configuration
$ npm run build          # tsc -p tsconfig.json  ->  dist/
$ npm run dev:mcp        # tsx packages/mcp-server/src/index.ts
$ daedalus mcp           # same server via the Commander CLI

# or from a built tree:
$ node dist/packages/mcp-server/src/index.js

# MCP host entry, verbatim from docs/MCP_USAGE.md:
{
  "mcpServers": {
    "daedalus": {
      "command": "node",
      "args": ["C:/Users/ianig/Desktop/daedalus/dist/packages/mcp-server/src/index.js"],
      "cwd": "C:/Users/ianig/Desktop/daedalus"
    }
  }
}

# when the cwd cannot be set, pin the state root instead:
$ DAEDALUS_HOME=/srv/daedalus node dist/packages/mcp-server/src/index.js

The server writes nothing to stdout other than the MCP protocol stream — there is no logging call anywhere in packages/mcp-server, which is correct for a stdio transport, where a stray console.log corrupts the JSON-RPC frame.

The agent prompt is part of the interface

Because the protocol cannot enforce role separation, the repository ships the rule as prose in two places — docs/MCP_USAGE.md and templates/AGENTS.md — and the same sentence is appended to every task string returned by proposeNextTask() at daedalus.ts:160.

“The task is not done because an agent says it is done. It is done when the artifact passes the required proof gates and the score meets the run contract.”

templates/AGENTS.md — Completion

Read against Fig. 5.2, that sentence is aspirational rather than descriptive: the gates are currently passed by the agent asserting numbers. It is nevertheless the correct thing to write down, because the whole point of the control plane is to make the assertion explicit, attached to a named proof, and recorded against a frozen manifest — so that when a real evaluator runner arrives, there is already a ledger for it to fill in.

Sheet notes

  1. The MCP server imports DaedalusCore from "../../core/src/index.js" — a relative source path, not a workspace package reference. There is no npm workspaces configuration in package.json; the monorepo layout is directories plus relative imports.
  2. new DaedalusCore() is constructed at module scope (index.ts:8) with the default StateStore, so the state root is fixed at import time from DAEDALUS_HOME or the process working directory.
  3. The server registers no MCP resources and no prompts. Everything is a tool.
  4. Tool descriptions say “Select evaluator packs”, but the implementation selects entries from evaluatorRegistry, not from the JSON files under evaluator-packs/. The two vocabularies have drifted.