codesynapt 0.0.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.
Files changed (61) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/LICENSE +686 -0
  3. package/LICENSES.md +141 -0
  4. package/README.md +331 -0
  5. package/electron/main.cjs +2849 -0
  6. package/electron/plugin-loader.cjs +184 -0
  7. package/electron/preload.cjs +108 -0
  8. package/package.json +216 -0
  9. package/packages/core/bin/codesynapt-mcp.cjs +611 -0
  10. package/packages/core/bin/codesynapt.cjs +1933 -0
  11. package/packages/core/legacy.js +300 -0
  12. package/packages/core/lib/control-server.cjs +1539 -0
  13. package/packages/core/lib/embedding.cjs +89 -0
  14. package/packages/core/lib/logger.cjs +63 -0
  15. package/packages/core/lib/search-cache.cjs +140 -0
  16. package/packages/core/lib/search-worker.cjs +255 -0
  17. package/packages/core/lib/search.cjs +211 -0
  18. package/packages/core/lib/symbol-graph.cjs +402 -0
  19. package/packages/core/lib/symbol-parser-js.cjs +542 -0
  20. package/packages/core/lib/symbol-parser-misc.cjs +394 -0
  21. package/packages/core/lib/symbol-parser-py.cjs +215 -0
  22. package/packages/core/lib/symbol-parser-treesitter.cjs +658 -0
  23. package/packages/core/lib/symbol-parser-tsc.cjs +332 -0
  24. package/packages/core/monorepo.js +310 -0
  25. package/packages/core/parser.js +2234 -0
  26. package/packages/core/scanner.js +623 -0
  27. package/plugin-api/LICENSE +21 -0
  28. package/plugin-api/README.md +114 -0
  29. package/plugin-api/docs/01-getting-started.md +197 -0
  30. package/plugin-api/docs/02-concepts.md +269 -0
  31. package/plugin-api/docs/api-reference.md +463 -0
  32. package/plugin-api/docs/troubleshooting.md +332 -0
  33. package/plugin-api/docs/types/exporter.md +377 -0
  34. package/plugin-api/docs/types/theme.md +312 -0
  35. package/plugin-api/examples/hello-world-plugin/README.md +70 -0
  36. package/plugin-api/examples/hello-world-plugin/main.js +36 -0
  37. package/plugin-api/examples/hello-world-plugin/manifest.json +12 -0
  38. package/plugin-api/examples/mermaid-exporter/README.md +125 -0
  39. package/plugin-api/examples/mermaid-exporter/main.js +58 -0
  40. package/plugin-api/examples/mermaid-exporter/manifest.json +12 -0
  41. package/plugin-api/examples/rust-parser/README.md +71 -0
  42. package/plugin-api/examples/rust-parser/main.js +123 -0
  43. package/plugin-api/examples/rust-parser/manifest.json +12 -0
  44. package/plugin-api/examples/sunset-theme/README.md +95 -0
  45. package/plugin-api/examples/sunset-theme/manifest.json +12 -0
  46. package/plugin-api/examples/sunset-theme/theme.css +31 -0
  47. package/plugin-api/package.json +20 -0
  48. package/plugin-api/types.d.ts +395 -0
  49. package/public/app.js +6837 -0
  50. package/public/backend.js +285 -0
  51. package/public/index.html +647 -0
  52. package/public/plugin-host.js +321 -0
  53. package/public/style.css +4359 -0
  54. package/public/vendor/three.module.js +53044 -0
  55. package/scripts/competitor-watch.mjs +144 -0
  56. package/scripts/copy-vendor.js +21 -0
  57. package/scripts/download-bundled-node.cjs +53 -0
  58. package/scripts/fuses-after-pack.cjs +34 -0
  59. package/scripts/license-check.js +119 -0
  60. package/scripts/perf-test.js +200 -0
  61. package/server.js +132 -0
@@ -0,0 +1,611 @@
1
+ #!/usr/bin/env node
2
+ // CodeSynapt MCP server — stdio JSON-RPC 2.0. Bridges Claude Code (or
3
+ // any MCP client) to the running Electron app's localhost control API.
4
+ //
5
+ // Register with Claude Code:
6
+ // claude mcp add codesynapt node /absolute/path/to/bin/codesynapt-mcp.cjs
7
+
8
+ const http = require('http')
9
+ const readline = require('readline')
10
+ const fs = require('fs')
11
+ const path = require('path')
12
+ const os = require('os')
13
+
14
+ // Resolve which port the running server is on.
15
+ // Priority: explicit env var > lock file (written by server) > default 7707.
16
+ function resolvePort() {
17
+ const envPort = process.env.CS_PORT || process.env.FG3D_PORT
18
+ if (envPort) return parseInt(envPort, 10)
19
+ try {
20
+ const lockPath = path.join(os.homedir(), '.codesynapt', 'port')
21
+ if (fs.existsSync(lockPath)) {
22
+ const p = parseInt(fs.readFileSync(lockPath, 'utf8').trim(), 10)
23
+ if (p > 0 && p < 65536) return p
24
+ }
25
+ } catch { /* fall through */ }
26
+ return 7707
27
+ }
28
+ const PORT = resolvePort()
29
+ const HOST = '127.0.0.1'
30
+
31
+ function apiReq(method, pathStr, query, body) {
32
+ return new Promise((resolve, reject) => {
33
+ let qs = ''
34
+ if (query) {
35
+ const parts = []
36
+ for (const [k, v] of Object.entries(query)) {
37
+ if (v !== undefined && v !== null) parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
38
+ }
39
+ if (parts.length) qs = '?' + parts.join('&')
40
+ }
41
+ const headers = {}
42
+ let payload = null
43
+ if (body !== undefined && body !== null) {
44
+ payload = typeof body === 'string' ? body : JSON.stringify(body)
45
+ headers['Content-Type'] = 'application/json'
46
+ headers['Content-Length'] = Buffer.byteLength(payload)
47
+ }
48
+ const r = http.request({ host: HOST, port: PORT, path: pathStr + qs, method, headers }, (res) => {
49
+ let chunks = []
50
+ res.on('data', (c) => chunks.push(c))
51
+ res.on('end', () => {
52
+ const text = Buffer.concat(chunks).toString('utf8')
53
+ let data
54
+ try { data = JSON.parse(text) } catch { data = text }
55
+ // Several actions (symbol/trace/history/legacy) are desktop-only and
56
+ // 404 against the headless `cs serve` backend. Turn the bare 404 into
57
+ // an actionable message instead of a confusing "unknown endpoint".
58
+ if (res.statusCode === 404 && data && typeof data === 'object' && /unknown endpoint/i.test(data.error || '')) {
59
+ data = { error: `'${method} ${pathStr}' is not available on the headless server (cs serve). This action requires the desktop app.` }
60
+ }
61
+ resolve({ status: res.statusCode, data })
62
+ })
63
+ })
64
+ r.on('error', (err) => {
65
+ if (err.code === 'ECONNREFUSED') {
66
+ reject(new Error(`codesynapt server is not running at ${HOST}:${PORT}. Start the desktop app first, or run 'cs serve'. Override port via CS_PORT.`))
67
+ } else reject(err)
68
+ })
69
+ if (payload) r.write(payload)
70
+ r.end()
71
+ })
72
+ }
73
+
74
+ function encId(id) { return id.split('/').map(encodeURIComponent).join('/') }
75
+ function bad(msg) { throw new Error(msg) }
76
+
77
+ // ─── Tool definitions ──────────────────────────────────────────
78
+ //
79
+ // Consolidated from 37 narrow tools into 8 intent-shaped tools.
80
+ // Each tool takes `action` enum that selects the underlying endpoint,
81
+ // plus any tool-specific parameters. This matches the 2026 MCP best
82
+ // practice: tool count low, organised by user intent rather than by
83
+ // REST endpoint mirror. The Electron app's /<endpoint> HTTP API is
84
+ // unchanged — only the MCP surface is consolidated.
85
+
86
+ const TOOLS = [
87
+ {
88
+ name: 'cs_summary',
89
+ description:
90
+ 'WHEN: once at the start of a new session on an unfamiliar project (skip if user is just chatting or working on a single small file). ~300 tokens.\n' +
91
+ 'Project structure overview.\n' +
92
+ 'actions:\n' +
93
+ ' · project — file count, edges, top hubs, top folders, ext mix, orphan count, confidence distribution (cheap, Layer 1)\n' +
94
+ ' · health — is the desktop app running, current root, history flag\n' +
95
+ ' · packages — monorepo packages with file counts and cross-package edges\n' +
96
+ ' · package_graph — package-to-package edge list (visual overview)\n' +
97
+ ' · package_detail — files + declared deps + cross-pkg edges for one package (requires name)',
98
+ inputSchema: {
99
+ type: 'object',
100
+ properties: {
101
+ action: { type: 'string', enum: ['project', 'health', 'packages', 'package_graph', 'package_detail'] },
102
+ name: { type: 'string', description: 'package_detail action — package name from packages list' },
103
+ },
104
+ required: ['action'],
105
+ },
106
+ handler: async ({ action, name }) => {
107
+ switch (action) {
108
+ case 'health': return (await apiReq('GET', '/health')).data
109
+ case 'project': return (await apiReq('GET', '/summary')).data
110
+ case 'packages': return (await apiReq('GET', '/packages')).data
111
+ case 'package_graph': return (await apiReq('GET', '/package-graph')).data
112
+ case 'package_detail':
113
+ if (!name) bad('package_detail requires { name }')
114
+ return (await apiReq('GET', '/package/' + encodeURIComponent(name))).data
115
+ default: bad('unknown action: ' + action)
116
+ }
117
+ },
118
+ },
119
+
120
+ {
121
+ name: 'cs_query',
122
+ description:
123
+ 'Code exploration — look up files, dependencies, content.\n' +
124
+ 'actions:\n' +
125
+ ' · list — paginated graph (filter by ext / minMass; sort=mass:desc default). Big repos: use limit.\n' +
126
+ ' · node — one file\'s metadata + imports + importedBy (requires id)\n' +
127
+ ' · deps — what this file imports (requires id)\n' +
128
+ ' · users — what imports this file = blast surface (requires id)\n' +
129
+ ' · find — file-ID/path substring search (requires q). For *contents*, use search.\n' +
130
+ ' · search — full-text CONTENT search across all tracked files (requires q). Returns line:col + snippet. mtime LRU cache → repeat queries are sub-50ms. WHEN: variable rename, hardcoded string hunt, i18n key tracking, "where is X used as text".\n' +
131
+ ' · read — file content, up to 2 MB (requires id)',
132
+ inputSchema: {
133
+ type: 'object',
134
+ properties: {
135
+ action: { type: 'string', enum: ['list', 'node', 'deps', 'users', 'find', 'search', 'read'] },
136
+ id: { type: 'string', description: 'file id, root-relative (e.g. src/auth.ts)' },
137
+ q: { type: 'string', description: 'find / search action — substring or regex pattern' },
138
+ limit: { type: 'number', description: 'list action — page size (0 = all)' },
139
+ offset: { type: 'number', description: 'list action — pagination offset' },
140
+ ext: { type: 'string', description: 'list action — filter by extension (e.g. "ts")' },
141
+ minMass:{ type: 'number', description: 'list action — only files with ≥ N importers' },
142
+ sort: { type: 'string', description: 'list action — mass:desc | size:desc | loc:desc | id:asc | insertion' },
143
+ regex: { type: 'boolean', description: 'search action — treat q as regex (default false)' },
144
+ caseSensitive: { type: 'boolean', description: 'search action — case-sensitive match (default false)' },
145
+ max: { type: 'number', description: 'search action — max total matches before early-bail (default 100)' },
146
+ },
147
+ required: ['action'],
148
+ },
149
+ handler: async ({ action, id, q, limit, offset, ext, minMass, sort, regex, caseSensitive, max }) => {
150
+ switch (action) {
151
+ case 'list':
152
+ return (await apiReq('GET', '/graph', {
153
+ limit: limit ?? 0, offset: offset ?? 0, ext, minMass, sort,
154
+ })).data
155
+ case 'node': if (!id) bad('node requires id'); return (await apiReq('GET', '/node/' + encId(id))).data
156
+ case 'deps': if (!id) bad('deps requires id'); return (await apiReq('GET', '/deps/' + encId(id))).data
157
+ case 'users': if (!id) bad('users requires id'); return (await apiReq('GET', '/users/' + encId(id))).data
158
+ case 'find': if (!q) bad('find requires q'); return (await apiReq('GET', '/find', { q })).data
159
+ case 'search': {
160
+ // 503 (scan in progress) → retry up to 3 times, 2 s apart.
161
+ if (!q) bad('search requires q')
162
+ const sParams = { q, regex: regex ? '1' : '0', case: caseSensitive ? '1' : '0', max: String(max ?? 100) }
163
+ let sr
164
+ for (let attempt = 0; attempt < 4; attempt++) {
165
+ sr = await apiReq('GET', '/search', sParams)
166
+ if (sr.status !== 503) break
167
+ if (attempt < 3) await new Promise((res) => setTimeout(res, 2000))
168
+ }
169
+ if (sr.status !== 200) bad(`search failed: ${sr.data?.error || 'status ' + sr.status}`)
170
+ return sr.data
171
+ }
172
+ case 'read': if (!id) bad('read requires id'); return (await apiReq('GET', '/file/' + encId(id))).data
173
+ default: bad('unknown action: ' + action)
174
+ }
175
+ },
176
+ },
177
+
178
+ {
179
+ name: 'cs_blast',
180
+ description:
181
+ 'WHEN: before NON-TRIVIAL file edits — refactors, function-signature changes, removed exports, multi-file work, hub files. SKIP for typos / comments / single-literal changes / docs.\n' +
182
+ 'Impact analysis — answers "is it safe to change this file?".\n' +
183
+ 'actions:\n' +
184
+ ' · safety — 🟢/🟡/🔴 verdict + reasons + one-line advice (the usual first call). (id, deep=true returns full impacted list)\n' +
185
+ ' · bundle — pack closest neighbours within token budget — call when safety=🟡/🔴 to load the right context (id, budget=8000, depth=3)\n' +
186
+ ' · radius — transitive dependents/dependencies via BFS, with token estimate (deeper analysis when needed) (id, depth=3, dir=users|deps)\n' +
187
+ 'RULE: 🔴 RISKY → STOP, surface to user, do not auto-edit. 🟡 CAUTION → call bundle first.\n' +
188
+ 'CAVEAT: if the response has a `caveat` field, the impact set contains files using dynamic/reflective/DI deps that static analysis CANNOT resolve — the real blast may be LARGER. Do NOT treat the count as complete; inspect caveat.dynamicFiles directly.',
189
+ inputSchema: {
190
+ type: 'object',
191
+ properties: {
192
+ action: { type: 'string', enum: ['radius', 'safety', 'bundle'] },
193
+ id: { type: 'string' },
194
+ depth: { type: 'number', description: 'radius / bundle BFS depth (1-10, default 3)' },
195
+ dir: { type: 'string', enum: ['users', 'deps'], description: 'radius direction (default users)' },
196
+ deep: { type: 'boolean', description: 'safety — include full impacted file list' },
197
+ budget: { type: 'number', description: 'bundle — token budget (default 8000)' },
198
+ locale: { type: 'string', enum: ['en', 'ko'], description: 'safety — response language (reasons + advice). default en.' },
199
+ full: { type: 'boolean', description: 'radius — return the complete per-hop file lists (default false: token-compact top-25/hop + counts)' },
200
+ },
201
+ required: ['action', 'id'],
202
+ },
203
+ handler: async ({ action, id, depth, dir, deep, budget, locale, full }) => {
204
+ if (!id) bad('id is required')
205
+ switch (action) {
206
+ case 'radius':
207
+ // Compact by default — a large blast's full per-hop list can cost
208
+ // tens of thousands of tokens; the agent gets counts + top files and
209
+ // can re-query with full=true if it truly needs every path.
210
+ return (await apiReq('GET', '/blast/' + encId(id), {
211
+ depth: depth ?? 3, dir: dir ?? 'users', compact: full ? null : '1',
212
+ })).data
213
+ case 'safety':
214
+ return (await apiReq('GET', '/safety/' + encId(id), { deep: deep ? '1' : null, locale })).data
215
+ case 'bundle':
216
+ return (await apiReq('GET', '/bundle/' + encId(id), { budget: budget ?? 8000, depth: depth ?? 3 })).data
217
+ default: bad('unknown action: ' + action)
218
+ }
219
+ },
220
+ },
221
+
222
+ {
223
+ name: 'cs_intent',
224
+ description:
225
+ 'Mapping from human intent ("payment feature", "/billing URL", "User model") to source files.\n' +
226
+ 'actions:\n' +
227
+ ' · feature — keyword → frontend/backend/shared file clusters (requires keyword)\n' +
228
+ ' · url — URL path → file (Next.js app/pages, Astro, SvelteKit). Without path returns all routes. (path optional)\n' +
229
+ ' · schema — DB models (Prisma / Drizzle / SQLAlchemy). With model returns definition + usage. (model optional)\n' +
230
+ ' · external — every external URL the project calls (grouped by domain)\n' +
231
+ 'Use when the user describes something by domain language rather than file name.',
232
+ inputSchema: {
233
+ type: 'object',
234
+ properties: {
235
+ action: { type: 'string', enum: ['feature', 'url', 'schema', 'external'] },
236
+ keyword: { type: 'string', description: 'feature action' },
237
+ path: { type: 'string', description: 'url action (optional — overview if omitted)' },
238
+ model: { type: 'string', description: 'schema action (optional — overview if omitted)' },
239
+ },
240
+ required: ['action'],
241
+ },
242
+ handler: async ({ action, keyword, path, model }) => {
243
+ switch (action) {
244
+ case 'feature':
245
+ if (!keyword) bad('feature requires keyword')
246
+ return (await apiReq('GET', '/feature/' + encodeURIComponent(keyword))).data
247
+ case 'url': return (await apiReq('GET', '/url', path ? { path } : null)).data
248
+ case 'schema': return (await apiReq('GET', '/schema', model ? { model } : null)).data
249
+ case 'external': return (await apiReq('GET', '/external')).data
250
+ default: bad('unknown action: ' + action)
251
+ }
252
+ },
253
+ },
254
+
255
+ {
256
+ name: 'cs_health',
257
+ description:
258
+ 'WHEN: \n' +
259
+ ' · preflight: before suggesting commit/deploy on a SIGNIFICANT change (skip for typo/doc-only)\n' +
260
+ ' · suggest: user is open-ended ("what next?") or you finished a task and have spare attention\n' +
261
+ ' · env / secrets / vendors / legacy: on-demand diagnosis\n' +
262
+ 'Project health checks + next-step recommendations.\n' +
263
+ 'actions:\n' +
264
+ ' · env — env vars: declared vs used cross-reference (var optional — overview if omitted)\n' +
265
+ ' · secrets — server-only env leaked into frontend bundles. RULE: fail at preflight, surface to user.\n' +
266
+ ' · vendors — third-party folder auto-detect → suggests .codesynaptignore entries.\n' +
267
+ ' · preflight — comprehensive deploy-readiness. RULE: do not suggest commit/deploy if overall=fail.\n' +
268
+ ' · suggest — rule-based "next thing to ask the AI to fix" (high/medium/low). Best opening move when stuck.\n' +
269
+ ' · legacy — orphan/path/filename/duplicate cleanup candidates with confidence scores (type optional)',
270
+ inputSchema: {
271
+ type: 'object',
272
+ properties: {
273
+ action: { type: 'string', enum: ['env', 'secrets', 'vendors', 'preflight', 'suggest', 'legacy'] },
274
+ var: { type: 'string', description: 'env action — single variable focus' },
275
+ top: { type: 'number', description: 'suggest — max suggestions (default 10)' },
276
+ type: { type: 'string', enum: ['orphan', 'path', 'filename', 'duplicate'], description: 'legacy — filter to one category' },
277
+ locale: { type: 'string', enum: ['en', 'ko'], description: 'preflight / suggest — response language. default en.' },
278
+ },
279
+ required: ['action'],
280
+ },
281
+ handler: async ({ action, var: v, top, type, locale }) => {
282
+ switch (action) {
283
+ case 'env': return (await apiReq('GET', '/env', v ? { var: v } : null)).data
284
+ case 'secrets': return (await apiReq('GET', '/secrets')).data
285
+ case 'vendors': return (await apiReq('GET', '/vendors')).data
286
+ case 'preflight': return (await apiReq('GET', '/preflight', locale ? { locale } : null)).data
287
+ case 'suggest': return (await apiReq('GET', '/suggest', { top: top ?? 10, locale })).data
288
+ case 'legacy': return (await apiReq('GET', '/legacy', type ? { type } : null)).data
289
+ default: bad('unknown action: ' + action)
290
+ }
291
+ },
292
+ },
293
+
294
+ {
295
+ name: 'cs_change',
296
+ description:
297
+ 'WHEN: editing a file ≥ 100 LOC, or a hub file, or anything cs_blast called caution/risky on.\n' +
298
+ 'SKIP for: typos / comments / formatting / brand-new files you just created this session — your own Edit tool is fine there.\n' +
299
+ 'Why prefer over own Edit when non-trivial: auto-snapshots, audit log, green pulse on the 3D node, AI trace overlay.\n' +
300
+ 'PREREQ for non-trivial: call cs_blast({action:\'safety\'}) first; if 🔴 do not proceed.\n' +
301
+ 'File modifications + history.\n' +
302
+ 'actions:\n' +
303
+ ' · write — overwrite file entirely (id, content). For full rewrites or small files.\n' +
304
+ ' · edit — precise find→replace (id, find, replace, replaceAll). Like Claude Code Edit tool.\n' +
305
+ ' Errors: 404=find not found, 409=ambiguous (count>1 without replaceAll).\n' +
306
+ ' · refresh — force re-parse one file (id). Use after external tools modified the file.\n' +
307
+ ' · history — list auto-snapshots for a file (id)\n' +
308
+ ' · restore — roll a file back to a snapshot (id, ts)',
309
+ inputSchema: {
310
+ type: 'object',
311
+ properties: {
312
+ action: { type: 'string', enum: ['write', 'edit', 'refresh', 'history', 'restore'] },
313
+ id: { type: 'string' },
314
+ content: { type: 'string', description: 'write action — new full content' },
315
+ find: { type: 'string', description: 'edit action — exact string to replace (whitespace-sensitive)' },
316
+ replace: { type: 'string', description: 'edit action — new string' },
317
+ replaceAll: { type: 'boolean', description: 'edit action — replace every occurrence (default false → must be unique)' },
318
+ ts: { type: 'number', description: 'restore action — snapshot timestamp from history' },
319
+ },
320
+ required: ['action', 'id'],
321
+ },
322
+ handler: async ({ action, id, content, find, replace, replaceAll, ts }) => {
323
+ if (!id) bad('id is required')
324
+ switch (action) {
325
+ case 'write':
326
+ if (typeof content !== 'string') bad('write requires content')
327
+ return (await apiReq('POST', '/write/' + encId(id), null, { content })).data
328
+ case 'edit':
329
+ if (typeof find !== 'string' || typeof replace !== 'string') bad('edit requires find and replace')
330
+ return (await apiReq('POST', '/edit/' + encId(id), null, { find, replace, replaceAll: replaceAll === true })).data
331
+ case 'refresh':
332
+ return (await apiReq('POST', '/refresh/' + encId(id))).data
333
+ case 'history':
334
+ return (await apiReq('GET', '/history/' + encId(id))).data
335
+ case 'restore':
336
+ if (!ts) bad('restore requires ts')
337
+ return (await apiReq('POST', '/restore/' + encId(id), { ts })).data
338
+ default: bad('unknown action: ' + action)
339
+ }
340
+ },
341
+ },
342
+
343
+ {
344
+ name: 'cs_trace',
345
+ description:
346
+ 'AI session traces + project history — review what an AI did, navigate the codebase chronologically.\n' +
347
+ 'actions:\n' +
348
+ ' · log — current session events (tool, id, ts). Filters: limit, tool.\n' +
349
+ ' · stats — top files / tool breakdown / duration for current session\n' +
350
+ ' · sessions — past sessions saved on disk (.codesynapt/traces)\n' +
351
+ ' · session — one past session detail (requires sessionId)\n' +
352
+ ' · clear — start a fresh session (previous archived)\n' +
353
+ ' · changes — files modified this session (with current vs first-seen size/loc)\n' +
354
+ ' · diff — first-seen → current diff for one file (requires id)\n' +
355
+ ' · tour — heuristic guided tour of the project (entry points + hubs + API hotspots)\n' +
356
+ ' · timeline — git history → when each tracked file first appeared',
357
+ inputSchema: {
358
+ type: 'object',
359
+ properties: {
360
+ action: { type: 'string', enum: ['log', 'stats', 'sessions', 'session', 'clear', 'changes', 'diff', 'tour', 'timeline'] },
361
+ limit: { type: 'number', description: 'log — only the most recent N' },
362
+ tool: { type: 'string', description: 'log — filter by tool name' },
363
+ sessionId: { type: 'number', description: 'session — past session id from sessions list' },
364
+ id: { type: 'string', description: 'diff — file id' },
365
+ },
366
+ required: ['action'],
367
+ },
368
+ handler: async ({ action, limit, tool, sessionId, id }) => {
369
+ switch (action) {
370
+ case 'log': return (await apiReq('GET', '/trace', { limit, tool })).data
371
+ case 'stats': return (await apiReq('GET', '/trace/stats')).data
372
+ case 'sessions': return (await apiReq('GET', '/trace/sessions')).data
373
+ case 'session':
374
+ if (!sessionId) bad('session requires sessionId')
375
+ return (await apiReq('GET', '/trace/session/' + sessionId)).data
376
+ case 'clear': return (await apiReq('POST', '/trace/clear')).data
377
+ case 'changes': return (await apiReq('GET', '/changes')).data
378
+ case 'diff':
379
+ if (!id) bad('diff requires id')
380
+ return (await apiReq('GET', '/changes/' + encId(id))).data
381
+ case 'tour': return (await apiReq('GET', '/tour')).data
382
+ case 'timeline': return (await apiReq('GET', '/timeline')).data
383
+ default: bad('unknown action: ' + action)
384
+ }
385
+ },
386
+ },
387
+
388
+ {
389
+ name: 'cs_ui',
390
+ description:
391
+ 'Desktop app UI control — focus the camera on a node, or open the inspector. Desktop-only side effect.\n' +
392
+ 'actions:\n' +
393
+ ' · focus — move the 3D camera to a node and highlight it (requires id)\n' +
394
+ ' · open — open the inspector panel for a node (requires id)',
395
+ inputSchema: {
396
+ type: 'object',
397
+ properties: {
398
+ action: { type: 'string', enum: ['focus', 'open'] },
399
+ id: { type: 'string' },
400
+ },
401
+ required: ['action', 'id'],
402
+ },
403
+ handler: async ({ action, id }) => {
404
+ if (!id) bad('id is required')
405
+ switch (action) {
406
+ case 'focus': return (await apiReq('POST', '/focus/' + encId(id))).data
407
+ case 'open': return (await apiReq('POST', '/open/' + encId(id))).data
408
+ default: bad('unknown action: ' + action)
409
+ }
410
+ },
411
+ },
412
+ // ─── Symbol mode (codegraph-equivalent layer) ──────────────────
413
+ // Three tools mirroring codegraph's most-used three. File mode tools
414
+ // above stay available — agents pick whichever matches the question.
415
+ {
416
+ name: 'cs_symbol_summary',
417
+ description:
418
+ 'Symbol-mode project overview: total symbols, breakdown by kind (function/class/struct/…) and edge kind. Triggers the symbol scan on first call after a project is loaded. Use this once at the start of a symbol-level investigation.',
419
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
420
+ handler: async () => (await apiReq('GET', '/symbol/summary')).data,
421
+ },
422
+ {
423
+ name: 'cs_symbol_search',
424
+ description:
425
+ 'Symbol-mode search and graph navigation. Four actions:\n' +
426
+ ' · find — symbols whose name contains `q` (case-insensitive)\n' +
427
+ ' · callers — symbols that call this symbol id\n' +
428
+ ' · callees — symbols that this symbol id calls\n' +
429
+ ' · node — one symbol with its source body\n' +
430
+ 'Use after cs_symbol_summary when an answer needs a specific function or class, not just the project shape.',
431
+ inputSchema: {
432
+ type: 'object',
433
+ properties: {
434
+ action: { type: 'string', enum: ['find', 'callers', 'callees', 'node'] },
435
+ q: { type: 'string', description: 'search query (action=find)' },
436
+ id: { type: 'string', description: 'symbol id (action=callers/callees/node)' },
437
+ limit: { type: 'number', description: 'max results (action=find), default 50' },
438
+ },
439
+ required: ['action'],
440
+ },
441
+ handler: async ({ action, q, id, limit }) => {
442
+ switch (action) {
443
+ case 'find':
444
+ if (!q) bad('q is required for action=find')
445
+ return (await apiReq('GET', '/symbol/find?q=' + encodeURIComponent(q) + (limit ? '&limit=' + limit : ''))).data
446
+ case 'callers':
447
+ if (!id) bad('id is required for action=callers')
448
+ return (await apiReq('GET', '/symbol/callers/' + encId(id))).data
449
+ case 'callees':
450
+ if (!id) bad('id is required for action=callees')
451
+ return (await apiReq('GET', '/symbol/callees/' + encId(id))).data
452
+ case 'node':
453
+ if (!id) bad('id is required for action=node')
454
+ return (await apiReq('GET', '/symbol/node/' + encId(id))).data
455
+ default: bad('unknown action: ' + action)
456
+ }
457
+ },
458
+ },
459
+ {
460
+ name: 'cs_symbol_explore',
461
+ description:
462
+ 'One-shot answer for a natural-language architecture question.\n' +
463
+ 'Returns symbols grouped by lifecycle (exact_match / active / entry /\n' +
464
+ 'normal / semantic / deprecated / legacy / test / aux / orphan).\n' +
465
+ 'Pick the group that matches the intent:\n' +
466
+ ' · exact_match — your query is a literal symbol name\n' +
467
+ ' · active — symbols other code calls a lot (live production)\n' +
468
+ ' · entry — exported main / handler / route / CLI bin\n' +
469
+ ' · semantic — pulled in by embedding similarity only (synonyms)\n' +
470
+ ' · deprecated / legacy / test / aux / orphan — usually skip\n' +
471
+ 'Each entry carries inDegree, outDegree, classification, ageDays,\n' +
472
+ 'and semSim so you can filter without a second call.',
473
+ inputSchema: {
474
+ type: 'object',
475
+ properties: {
476
+ q: { type: 'string', description: 'the natural-language question' },
477
+ budget: { type: 'number', description: 'token budget for snippet bodies (default 8000)' },
478
+ },
479
+ required: ['q'],
480
+ },
481
+ handler: async ({ q, budget }) => {
482
+ if (!q) bad('q is required')
483
+ let u = '/symbol/explore?q=' + encodeURIComponent(q)
484
+ if (budget) u += '&budget=' + budget
485
+ return (await apiReq('GET', u)).data
486
+ },
487
+ },
488
+ ]
489
+
490
+ // ─── JSON-RPC over stdio ───────────────────────────────────────
491
+ function send(msg) {
492
+ process.stdout.write(JSON.stringify(msg) + '\n')
493
+ }
494
+
495
+ function respond(id, result) {
496
+ send({ jsonrpc: '2.0', id, result })
497
+ }
498
+ function respondError(id, code, message) {
499
+ send({ jsonrpc: '2.0', id, error: { code, message } })
500
+ }
501
+
502
+ async function handle(msg) {
503
+ const { id, method, params } = msg
504
+ try {
505
+ if (method === 'initialize') {
506
+ return respond(id, {
507
+ protocolVersion: '2024-11-05',
508
+ capabilities: { tools: {} },
509
+ serverInfo: { name: 'codesynapt', version: '0.2.0' },
510
+ })
511
+ }
512
+ if (method === 'notifications/initialized') {
513
+ return // notification — no response
514
+ }
515
+ if (method === 'tools/list') {
516
+ return respond(id, {
517
+ tools: TOOLS.map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema })),
518
+ })
519
+ }
520
+ if (method === 'tools/call') {
521
+ const tool = TOOLS.find((t) => t.name === params?.name)
522
+ if (!tool) return respondError(id, -32601, `unknown tool: ${params?.name}`)
523
+ const result = await tool.handler(params.arguments || {})
524
+ return respond(id, {
525
+ content: [{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result, null, 2) }],
526
+ })
527
+ }
528
+ if (method === 'ping') {
529
+ return respond(id, {})
530
+ }
531
+ respondError(id, -32601, `unknown method: ${method}`)
532
+ } catch (err) {
533
+ respondError(id, -32000, err.message)
534
+ }
535
+ }
536
+
537
+ // ─── Transports: stdio (default) or HTTP (--http) ──────────────
538
+ //
539
+ // stdio is the default MCP transport (Claude Code, Cursor, Continue).
540
+ // HTTP is the 2026 MCP standard "Streamable HTTP" — POST /mcp body
541
+ // is a JSON-RPC request, response is the JSON-RPC response. Used by
542
+ // remote / cloud-hosted MCP clients that can't spawn a subprocess.
543
+ //
544
+ // Usage:
545
+ // codesynapt-mcp # stdio (default)
546
+ // codesynapt-mcp --http # HTTP on default port 7708
547
+ // codesynapt-mcp --http --port 9999 # HTTP on custom port
548
+
549
+ const cliArgs = process.argv.slice(2)
550
+ const isHttp = cliArgs.includes('--http')
551
+ let httpPort = 7708 // distinct from control API's 7707
552
+ for (let i = 0; i < cliArgs.length; i++) {
553
+ if (cliArgs[i] === '--port' && cliArgs[i+1]) httpPort = parseInt(cliArgs[++i], 10)
554
+ }
555
+
556
+ if (isHttp) {
557
+ // HTTP transport — POST /mcp with JSON-RPC body
558
+ const server = http.createServer(async (req, res) => {
559
+ // CORS
560
+ res.setHeader('Access-Control-Allow-Origin', '*')
561
+ res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS')
562
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
563
+ if (req.method === 'OPTIONS') { res.writeHead(204); return res.end() }
564
+ if (req.method === 'GET' && req.url === '/') {
565
+ res.writeHead(200, { 'Content-Type': 'application/json' })
566
+ return res.end(JSON.stringify({
567
+ name: 'codesynapt-mcp',
568
+ transport: 'streamable-http',
569
+ version: '0.1.0',
570
+ endpoints: ['POST /mcp', 'GET /mcp/events (SSE)'],
571
+ toolCount: TOOLS.length,
572
+ }))
573
+ }
574
+ if (req.method === 'POST' && req.url === '/mcp') {
575
+ let chunks = []
576
+ req.on('data', (c) => chunks.push(c))
577
+ req.on('end', async () => {
578
+ let msg
579
+ try { msg = JSON.parse(Buffer.concat(chunks).toString('utf8')) }
580
+ catch { res.writeHead(400, { 'Content-Type': 'application/json' }); return res.end('{"error":"invalid json"}') }
581
+ // Capture the response from handle() instead of writing to stdout
582
+ const captured = await new Promise((resolve) => {
583
+ const original = process.stdout.write
584
+ let buf = ''
585
+ process.stdout.write = (s) => { buf += s; return true }
586
+ handle(msg).finally(() => {
587
+ process.stdout.write = original
588
+ resolve(buf.trim())
589
+ })
590
+ })
591
+ res.writeHead(200, { 'Content-Type': 'application/json' })
592
+ res.end(captured || '{}')
593
+ })
594
+ return
595
+ }
596
+ res.writeHead(404, { 'Content-Type': 'application/json' })
597
+ res.end('{"error":"not found"}')
598
+ })
599
+ server.listen(httpPort, '127.0.0.1', () => {
600
+ process.stderr.write(`[cs-mcp] HTTP transport on http://127.0.0.1:${httpPort}/mcp\n`)
601
+ })
602
+ } else {
603
+ // stdio transport (default)
604
+ const rl = readline.createInterface({ input: process.stdin })
605
+ rl.on('line', (line) => {
606
+ if (!line.trim()) return
607
+ let msg
608
+ try { msg = JSON.parse(line) } catch { return }
609
+ handle(msg)
610
+ })
611
+ }