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.
- package/CHANGELOG.md +17 -0
- package/LICENSE +686 -0
- package/LICENSES.md +141 -0
- package/README.md +331 -0
- package/electron/main.cjs +2849 -0
- package/electron/plugin-loader.cjs +184 -0
- package/electron/preload.cjs +108 -0
- package/package.json +216 -0
- package/packages/core/bin/codesynapt-mcp.cjs +611 -0
- package/packages/core/bin/codesynapt.cjs +1933 -0
- package/packages/core/legacy.js +300 -0
- package/packages/core/lib/control-server.cjs +1539 -0
- package/packages/core/lib/embedding.cjs +89 -0
- package/packages/core/lib/logger.cjs +63 -0
- package/packages/core/lib/search-cache.cjs +140 -0
- package/packages/core/lib/search-worker.cjs +255 -0
- package/packages/core/lib/search.cjs +211 -0
- package/packages/core/lib/symbol-graph.cjs +402 -0
- package/packages/core/lib/symbol-parser-js.cjs +542 -0
- package/packages/core/lib/symbol-parser-misc.cjs +394 -0
- package/packages/core/lib/symbol-parser-py.cjs +215 -0
- package/packages/core/lib/symbol-parser-treesitter.cjs +658 -0
- package/packages/core/lib/symbol-parser-tsc.cjs +332 -0
- package/packages/core/monorepo.js +310 -0
- package/packages/core/parser.js +2234 -0
- package/packages/core/scanner.js +623 -0
- package/plugin-api/LICENSE +21 -0
- package/plugin-api/README.md +114 -0
- package/plugin-api/docs/01-getting-started.md +197 -0
- package/plugin-api/docs/02-concepts.md +269 -0
- package/plugin-api/docs/api-reference.md +463 -0
- package/plugin-api/docs/troubleshooting.md +332 -0
- package/plugin-api/docs/types/exporter.md +377 -0
- package/plugin-api/docs/types/theme.md +312 -0
- package/plugin-api/examples/hello-world-plugin/README.md +70 -0
- package/plugin-api/examples/hello-world-plugin/main.js +36 -0
- package/plugin-api/examples/hello-world-plugin/manifest.json +12 -0
- package/plugin-api/examples/mermaid-exporter/README.md +125 -0
- package/plugin-api/examples/mermaid-exporter/main.js +58 -0
- package/plugin-api/examples/mermaid-exporter/manifest.json +12 -0
- package/plugin-api/examples/rust-parser/README.md +71 -0
- package/plugin-api/examples/rust-parser/main.js +123 -0
- package/plugin-api/examples/rust-parser/manifest.json +12 -0
- package/plugin-api/examples/sunset-theme/README.md +95 -0
- package/plugin-api/examples/sunset-theme/manifest.json +12 -0
- package/plugin-api/examples/sunset-theme/theme.css +31 -0
- package/plugin-api/package.json +20 -0
- package/plugin-api/types.d.ts +395 -0
- package/public/app.js +6837 -0
- package/public/backend.js +285 -0
- package/public/index.html +647 -0
- package/public/plugin-host.js +321 -0
- package/public/style.css +4359 -0
- package/public/vendor/three.module.js +53044 -0
- package/scripts/competitor-watch.mjs +144 -0
- package/scripts/copy-vendor.js +21 -0
- package/scripts/download-bundled-node.cjs +53 -0
- package/scripts/fuses-after-pack.cjs +34 -0
- package/scripts/license-check.js +119 -0
- package/scripts/perf-test.js +200 -0
- 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
|
+
}
|