● Zion Boggan
repos/TreeTrace/src/mcp.js
zionboggan.com ↗
208 lines · javascript
History for this file →
1
import { createInterface } from 'node:readline';
2
import { resolve } from 'node:path';
3
import { parseArgs, loadRedactedTree, detectProjectName, assertClean, sourceTypeFor } from './cli.js';
4
import { renderHandoff } from './handoff.js';
5
import { renderLessonsMarkdown, analyzeTree, renderRejectionsJson } from './analyze.js';
6
import { renderSecurityReport } from './security-report.js';
7
import { renderHallucinationsJson } from './hallucinate.js';
8
import { renderJson } from './render-json.js';
9
import { SCHEMA_VERSION } from './config.js';
10
import { TreetraceError, ExitCode } from './util.js';
11
 
12
const PROTOCOL_VERSION = '2024-11-05';
13
const MAX_REQUEST_BYTES = 1048576;
14
 
15
const TOOL_DEFS = [
16
  {
17
    name: 'handoff',
18
    description: 'Continuation brief for the next agent: goal, accepted decisions, constraints, and dead ends. Read only.',
19
    inputSchema: { type: 'object', properties: {}, additionalProperties: false },
20
  },
21
  {
22
    name: 'lessons',
23
    description: 'Accepted constraints and repeated corrections distilled from the session lineage. Read only.',
24
    inputSchema: { type: 'object', properties: {}, additionalProperties: false },
25
  },
26
  {
27
    name: 'security_summary',
28
    description: 'Evidence-backed security-sensitive touches, test changes, risky commands, and hallucinated references. Read only.',
29
    inputSchema: { type: 'object', properties: {}, additionalProperties: false },
30
  },
31
  {
32
    name: 'eval_candidates',
33
    description: 'Compact regression cases derived from session corrections and hallucinated references. Read only.',
34
    inputSchema: { type: 'object', properties: {}, additionalProperties: false },
35
  },
36
  {
37
    name: 'tree',
38
    description: 'Full prompt-lineage tree as canonical JSON (nodes, stats, analysis). The structured counterpart to the Markdown reports. Read only.',
39
    inputSchema: { type: 'object', properties: {}, additionalProperties: false },
40
  },
41
  {
42
    name: 'rejections_summary',
43
    description: 'Typed rejection / refusal / decline events captured on the session (tool declines, interrupts, permission denials, tool errors, model refusals). Read only.',
44
    inputSchema: { type: 'object', properties: {}, additionalProperties: false },
45
  },
46
];
47
 
48
export async function startMcpServer({ argv, version }, io = {}) {
49
  const input = io.input || process.stdin;
50
  const output = io.output || process.stdout;
51
  const opts = parseArgs((argv || []).filter((a) => a !== 'mcp' && a !== '--mcp'));
52
  if (opts.stdin) {
53
    throw new TreetraceError(
54
      'treetrace mcp does not support --stdin: stdin is the JSON-RPC transport for the MCP server. ' +
55
        'Point the server at a project with --dir, or import a transcript with --file.',
56
      ExitCode.USAGE
57
    );
58
  }
59
  const projectDir = resolve(opts.dir || process.cwd());
60
  const projectName = detectProjectName(projectDir);
61
 
62
  let cache = null;
63
  let inFlight = null;
64
  const ensureTree = async () => {
65
    if (cache) return cache;
66
    if (!inFlight) {
67
      inFlight = (async () => {
68
        const { tree, decisions, sourceTool } = await loadRedactedTree(opts, projectDir, projectName, () => {}, { forceAuto: true });
69
        cache = {
70
          tree,
71
          decisions,
72
          renderOpts: {
73
            projectName,
74
            version,
75
            projectDir,
76
            generatedAt: new Date().toISOString(),
77
            sourceType: sourceTypeFor(sourceTool),
78
          },
79
        };
80
        return cache;
81
      })().finally(() => {
82
        inFlight = null;
83
      });
84
    }
85
    return inFlight;
86
  };
87
 
88
  return new Promise((resolveServer) => {
89
    const rl = createInterface({ input, crlfDelay: Infinity });
90
    const send = (msg) => output.write(`${JSON.stringify(msg)}\n`);
91
 
92
    rl.on('line', async (line) => {
93
      const text = line.trim();
94
      if (!text) return;
95
      if (text.length > MAX_REQUEST_BYTES) {
96
        send({ jsonrpc: '2.0', id: null, error: { code: -32600, message: 'Invalid Request: request exceeds size limit' } });
97
        return;
98
      }
99
      let req;
100
      try {
101
        req = JSON.parse(text);
102
      } catch {
103
        send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } });
104
        return;
105
      }
106
      if (Array.isArray(req)) {
107
        send({ jsonrpc: '2.0', id: null, error: { code: -32600, message: 'Invalid Request: JSON-RPC batch requests are not supported' } });
108
        return;
109
      }
110
      try {
111
        await handle(req, send, ensureTree, version);
112
      } catch (err) {
113
        if (isRequestWithId(req)) {
114
          send({
115
            jsonrpc: '2.0',
116
            id: req.id,
117
            error: { code: -32603, message: `Internal error: ${err && err.message ? err.message : 'unknown'}` },
118
          });
119
        }
120
      }
121
    });
122
    rl.on('close', () => resolveServer());
123
  });
124
}
125
 
126
function isRequestWithId(req) {
127
  return Boolean(req) && typeof req === 'object' && !Array.isArray(req) && 'id' in req;
128
}
129
 
130
async function handle(req, send, ensureTree, version) {
131
  const hasId = isRequestWithId(req);
132
  if (!req || req.jsonrpc !== '2.0' || typeof req.method !== 'string') {
133
    if (hasId) send({ jsonrpc: '2.0', id: req.id, error: { code: -32600, message: 'Invalid Request' } });
134
    return;
135
  }
136
  const isNotification = !hasId;
137
  const reply = (result) => { if (!isNotification) send({ jsonrpc: '2.0', id: req.id, result }); };
138
  const fail = (code, message) => { if (!isNotification) send({ jsonrpc: '2.0', id: req.id, error: { code, message } }); };
139
 
140
  switch (req.method) {
141
    case 'initialize':
142
      reply({
143
        protocolVersion: PROTOCOL_VERSION,
144
        capabilities: { tools: {} },
145
        serverInfo: { name: 'treetrace', version: version || '0.0.0' },
146
      });
147
      return;
148
    case 'notifications/initialized':
149
    case 'initialized':
150
      return;
151
    case 'ping':
152
      reply({});
153
      return;
154
    case 'tools/list':
155
      reply({ tools: TOOL_DEFS });
156
      return;
157
    case 'tools/call': {
158
      const params = req.params || {};
159
      const name = params.name;
160
      const def = TOOL_DEFS.find((t) => t.name === name);
161
      if (!def) {
162
        fail(-32602, `Unknown tool: ${name}`);
163
        return;
164
      }
165
      const args = params.arguments;
166
      if (args !== undefined && args !== null) {
167
        if (typeof args !== 'object' || Array.isArray(args) || Object.keys(args).length > 0) {
168
          fail(-32602, `Tool ${name} accepts no arguments`);
169
          return;
170
        }
171
      }
172
      const { tree, decisions, renderOpts } = await ensureTree();
173
      const text = renderTool(name, tree, renderOpts);
174
      assertClean(text, decisions, `mcp tool ${name}`);
175
      reply({ content: [{ type: 'text', text }], isError: false });
176
      return;
177
    }
178
    default:
179
      fail(-32601, `Method not found: ${req.method}`);
180
  }
181
}
182
 
183
function renderTool(name, tree, renderOpts) {
184
  switch (name) {
185
    case 'handoff':
186
      return renderHandoff(tree, renderOpts);
187
    case 'lessons':
188
      return renderLessonsMarkdown(tree, renderOpts);
189
    case 'security_summary':
190
      return renderSecurityReport(tree, renderOpts.projectDir || null, renderOpts);
191
    case 'eval_candidates': {
192
      const analysis = analyzeTree(tree);
193
      const hall = renderHallucinationsJson(tree, renderOpts.projectDir || null, renderOpts);
194
      const payload = {
195
        schemaVersion: SCHEMA_VERSION,
196
        evalCandidates: analysis.evalCandidates,
197
        hallucinationEvalCandidates: hall.hallucinations.map((h) => h.evalCandidate),
198
      };
199
      return JSON.stringify(payload, null, 2);
200
    }
201
    case 'tree':
202
      return JSON.stringify(renderJson(tree, renderOpts), null, 2);
203
    case 'rejections_summary':
204
      return JSON.stringify(renderRejectionsJson(tree, renderOpts), null, 2);
205
    default:
206
      return '';
207
  }
208
}