codebase-onboarder 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (216) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +275 -0
  3. package/bin/onboarder.js +14 -0
  4. package/bin/postinstall.js +6 -0
  5. package/cli/commands.js +436 -0
  6. package/cli/main.js +129 -0
  7. package/cli/prompt.js +101 -0
  8. package/cli/ui.js +55 -0
  9. package/cli/wizard.js +325 -0
  10. package/package.json +48 -0
  11. package/public/app.js +1339 -0
  12. package/public/index.html +455 -0
  13. package/public/js/about.js +284 -0
  14. package/public/js/aiDraft.js +163 -0
  15. package/public/js/analysisPanel.js +270 -0
  16. package/public/js/analysisReport.js +154 -0
  17. package/public/js/api.js +264 -0
  18. package/public/js/atlas.js +98 -0
  19. package/public/js/blameView.js +41 -0
  20. package/public/js/codeTab.js +256 -0
  21. package/public/js/codeViewer.js +184 -0
  22. package/public/js/components/FileChip.js +102 -0
  23. package/public/js/components/MetricSparkline.js +89 -0
  24. package/public/js/components/RiskBadge.js +105 -0
  25. package/public/js/deepAnalysisView.js +496 -0
  26. package/public/js/diagramPane.js +154 -0
  27. package/public/js/diffView.js +352 -0
  28. package/public/js/docsView.js +566 -0
  29. package/public/js/fileSourceBrowser.js +47 -0
  30. package/public/js/flameGraph.js +75 -0
  31. package/public/js/forceGraph.js +592 -0
  32. package/public/js/heatmap.js +185 -0
  33. package/public/js/highlight.js +122 -0
  34. package/public/js/html.js +50 -0
  35. package/public/js/insightsView.js +236 -0
  36. package/public/js/inspector.js +535 -0
  37. package/public/js/llm.js +345 -0
  38. package/public/js/mapView.js +159 -0
  39. package/public/js/markdown.js +119 -0
  40. package/public/js/mindmap.js +289 -0
  41. package/public/js/repoFiles.js +44 -0
  42. package/public/js/sbomView.js +142 -0
  43. package/public/js/scanCache.js +76 -0
  44. package/public/js/search.js +874 -0
  45. package/public/js/serverSettings.js +169 -0
  46. package/public/js/state.js +192 -0
  47. package/public/js/tour.js +17 -0
  48. package/public/js/transitions.js +15 -0
  49. package/public/js/tree.js +200 -0
  50. package/public/js/workflowsView.js +95 -0
  51. package/public/styles.css +2767 -0
  52. package/public/vendor/mermaid.min.js +3587 -0
  53. package/public/vendor/monaco/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
  54. package/public/vendor/monaco/vs/base/worker/workerMain.js +31 -0
  55. package/public/vendor/monaco/vs/basic-languages/abap/abap.js +10 -0
  56. package/public/vendor/monaco/vs/basic-languages/apex/apex.js +10 -0
  57. package/public/vendor/monaco/vs/basic-languages/azcli/azcli.js +10 -0
  58. package/public/vendor/monaco/vs/basic-languages/bat/bat.js +10 -0
  59. package/public/vendor/monaco/vs/basic-languages/bicep/bicep.js +11 -0
  60. package/public/vendor/monaco/vs/basic-languages/cameligo/cameligo.js +10 -0
  61. package/public/vendor/monaco/vs/basic-languages/clojure/clojure.js +10 -0
  62. package/public/vendor/monaco/vs/basic-languages/coffee/coffee.js +10 -0
  63. package/public/vendor/monaco/vs/basic-languages/cpp/cpp.js +10 -0
  64. package/public/vendor/monaco/vs/basic-languages/csharp/csharp.js +10 -0
  65. package/public/vendor/monaco/vs/basic-languages/csp/csp.js +10 -0
  66. package/public/vendor/monaco/vs/basic-languages/css/css.js +12 -0
  67. package/public/vendor/monaco/vs/basic-languages/cypher/cypher.js +10 -0
  68. package/public/vendor/monaco/vs/basic-languages/dart/dart.js +10 -0
  69. package/public/vendor/monaco/vs/basic-languages/dockerfile/dockerfile.js +10 -0
  70. package/public/vendor/monaco/vs/basic-languages/ecl/ecl.js +10 -0
  71. package/public/vendor/monaco/vs/basic-languages/elixir/elixir.js +10 -0
  72. package/public/vendor/monaco/vs/basic-languages/flow9/flow9.js +10 -0
  73. package/public/vendor/monaco/vs/basic-languages/freemarker2/freemarker2.js +12 -0
  74. package/public/vendor/monaco/vs/basic-languages/fsharp/fsharp.js +10 -0
  75. package/public/vendor/monaco/vs/basic-languages/go/go.js +10 -0
  76. package/public/vendor/monaco/vs/basic-languages/graphql/graphql.js +10 -0
  77. package/public/vendor/monaco/vs/basic-languages/handlebars/handlebars.js +10 -0
  78. package/public/vendor/monaco/vs/basic-languages/hcl/hcl.js +10 -0
  79. package/public/vendor/monaco/vs/basic-languages/html/html.js +10 -0
  80. package/public/vendor/monaco/vs/basic-languages/ini/ini.js +10 -0
  81. package/public/vendor/monaco/vs/basic-languages/java/java.js +10 -0
  82. package/public/vendor/monaco/vs/basic-languages/javascript/javascript.js +10 -0
  83. package/public/vendor/monaco/vs/basic-languages/julia/julia.js +10 -0
  84. package/public/vendor/monaco/vs/basic-languages/kotlin/kotlin.js +10 -0
  85. package/public/vendor/monaco/vs/basic-languages/less/less.js +11 -0
  86. package/public/vendor/monaco/vs/basic-languages/lexon/lexon.js +10 -0
  87. package/public/vendor/monaco/vs/basic-languages/liquid/liquid.js +10 -0
  88. package/public/vendor/monaco/vs/basic-languages/lua/lua.js +10 -0
  89. package/public/vendor/monaco/vs/basic-languages/m3/m3.js +10 -0
  90. package/public/vendor/monaco/vs/basic-languages/markdown/markdown.js +10 -0
  91. package/public/vendor/monaco/vs/basic-languages/mdx/mdx.js +10 -0
  92. package/public/vendor/monaco/vs/basic-languages/mips/mips.js +10 -0
  93. package/public/vendor/monaco/vs/basic-languages/msdax/msdax.js +10 -0
  94. package/public/vendor/monaco/vs/basic-languages/mysql/mysql.js +10 -0
  95. package/public/vendor/monaco/vs/basic-languages/objective-c/objective-c.js +10 -0
  96. package/public/vendor/monaco/vs/basic-languages/pascal/pascal.js +10 -0
  97. package/public/vendor/monaco/vs/basic-languages/pascaligo/pascaligo.js +10 -0
  98. package/public/vendor/monaco/vs/basic-languages/perl/perl.js +10 -0
  99. package/public/vendor/monaco/vs/basic-languages/pgsql/pgsql.js +10 -0
  100. package/public/vendor/monaco/vs/basic-languages/php/php.js +10 -0
  101. package/public/vendor/monaco/vs/basic-languages/pla/pla.js +10 -0
  102. package/public/vendor/monaco/vs/basic-languages/postiats/postiats.js +10 -0
  103. package/public/vendor/monaco/vs/basic-languages/powerquery/powerquery.js +10 -0
  104. package/public/vendor/monaco/vs/basic-languages/powershell/powershell.js +10 -0
  105. package/public/vendor/monaco/vs/basic-languages/protobuf/protobuf.js +11 -0
  106. package/public/vendor/monaco/vs/basic-languages/pug/pug.js +10 -0
  107. package/public/vendor/monaco/vs/basic-languages/python/python.js +10 -0
  108. package/public/vendor/monaco/vs/basic-languages/qsharp/qsharp.js +10 -0
  109. package/public/vendor/monaco/vs/basic-languages/r/r.js +10 -0
  110. package/public/vendor/monaco/vs/basic-languages/razor/razor.js +10 -0
  111. package/public/vendor/monaco/vs/basic-languages/redis/redis.js +10 -0
  112. package/public/vendor/monaco/vs/basic-languages/redshift/redshift.js +10 -0
  113. package/public/vendor/monaco/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
  114. package/public/vendor/monaco/vs/basic-languages/ruby/ruby.js +10 -0
  115. package/public/vendor/monaco/vs/basic-languages/rust/rust.js +10 -0
  116. package/public/vendor/monaco/vs/basic-languages/sb/sb.js +10 -0
  117. package/public/vendor/monaco/vs/basic-languages/scala/scala.js +10 -0
  118. package/public/vendor/monaco/vs/basic-languages/scheme/scheme.js +10 -0
  119. package/public/vendor/monaco/vs/basic-languages/scss/scss.js +12 -0
  120. package/public/vendor/monaco/vs/basic-languages/shell/shell.js +10 -0
  121. package/public/vendor/monaco/vs/basic-languages/solidity/solidity.js +10 -0
  122. package/public/vendor/monaco/vs/basic-languages/sophia/sophia.js +10 -0
  123. package/public/vendor/monaco/vs/basic-languages/sparql/sparql.js +10 -0
  124. package/public/vendor/monaco/vs/basic-languages/sql/sql.js +10 -0
  125. package/public/vendor/monaco/vs/basic-languages/st/st.js +10 -0
  126. package/public/vendor/monaco/vs/basic-languages/swift/swift.js +13 -0
  127. package/public/vendor/monaco/vs/basic-languages/systemverilog/systemverilog.js +10 -0
  128. package/public/vendor/monaco/vs/basic-languages/tcl/tcl.js +10 -0
  129. package/public/vendor/monaco/vs/basic-languages/twig/twig.js +10 -0
  130. package/public/vendor/monaco/vs/basic-languages/typescript/typescript.js +10 -0
  131. package/public/vendor/monaco/vs/basic-languages/typespec/typespec.js +10 -0
  132. package/public/vendor/monaco/vs/basic-languages/vb/vb.js +10 -0
  133. package/public/vendor/monaco/vs/basic-languages/wgsl/wgsl.js +307 -0
  134. package/public/vendor/monaco/vs/basic-languages/xml/xml.js +10 -0
  135. package/public/vendor/monaco/vs/basic-languages/yaml/yaml.js +10 -0
  136. package/public/vendor/monaco/vs/editor/editor.main.css +8 -0
  137. package/public/vendor/monaco/vs/editor/editor.main.js +798 -0
  138. package/public/vendor/monaco/vs/language/css/cssMode.js +13 -0
  139. package/public/vendor/monaco/vs/language/css/cssWorker.js +77 -0
  140. package/public/vendor/monaco/vs/language/html/htmlMode.js +13 -0
  141. package/public/vendor/monaco/vs/language/html/htmlWorker.js +454 -0
  142. package/public/vendor/monaco/vs/language/json/jsonMode.js +19 -0
  143. package/public/vendor/monaco/vs/language/json/jsonWorker.js +42 -0
  144. package/public/vendor/monaco/vs/language/typescript/tsMode.js +20 -0
  145. package/public/vendor/monaco/vs/language/typescript/tsWorker.js +51328 -0
  146. package/public/vendor/monaco/vs/loader.js +11 -0
  147. package/public/vendor/monaco/worker-boot.js +9 -0
  148. package/server/.fuse_hidden0000000800000001 +36 -0
  149. package/server/apiDiff.js +25 -0
  150. package/server/apiDocs.js +67 -0
  151. package/server/apiFile.js +36 -0
  152. package/server/apiGitBlame.js +63 -0
  153. package/server/apiMcp.js +110 -0
  154. package/server/apiScan.js +119 -0
  155. package/server/apiSearch.js +253 -0
  156. package/server/apiSettings.js +118 -0
  157. package/server/apiTools.js +90 -0
  158. package/server/config.js +227 -0
  159. package/server/fileSourceNode.js +44 -0
  160. package/server/gitClone.js +95 -0
  161. package/server/gitDiff.js +184 -0
  162. package/server/gitHistory.js +110 -0
  163. package/server/htmlText.js +44 -0
  164. package/server/http.js +55 -0
  165. package/server/httpGuards.js +87 -0
  166. package/server/index.js +156 -0
  167. package/server/llmProxy.js +162 -0
  168. package/server/logger.js +29 -0
  169. package/server/mcp/analysis.js +209 -0
  170. package/server/mcp/http.js +213 -0
  171. package/server/mcp/runner.js +278 -0
  172. package/server/mcp/server.js +241 -0
  173. package/server/mcp/standalone.js +42 -0
  174. package/server/mcp/tools.js +683 -0
  175. package/server/paths.js +39 -0
  176. package/server/router.js +218 -0
  177. package/server/searchIndex.js +118 -0
  178. package/server/sessions.js +163 -0
  179. package/server/static.js +59 -0
  180. package/server/tools/install.js +246 -0
  181. package/server/tools/parse.js +170 -0
  182. package/server/tools/platform.js +91 -0
  183. package/server/tools/registry.js +212 -0
  184. package/server/tools/scan.js +136 -0
  185. package/server/tools.js +212 -0
  186. package/server/tunnel.js +92 -0
  187. package/shared/analyzer/docs.js +135 -0
  188. package/shared/analyzer/explainLocal.js +163 -0
  189. package/shared/analyzer/graph.js +461 -0
  190. package/shared/analyzer/health.js +215 -0
  191. package/shared/analyzer/history.js +146 -0
  192. package/shared/analyzer/languages/csharp.js +39 -0
  193. package/shared/analyzer/languages/generic.js +131 -0
  194. package/shared/analyzer/languages/go.js +70 -0
  195. package/shared/analyzer/languages/index.js +43 -0
  196. package/shared/analyzer/languages/java.js +39 -0
  197. package/shared/analyzer/languages/javascript.js +240 -0
  198. package/shared/analyzer/languages/python.js +120 -0
  199. package/shared/analyzer/languages/rust.js +42 -0
  200. package/shared/analyzer/languages/typescript.js +138 -0
  201. package/shared/analyzer/licenses.js +151 -0
  202. package/shared/analyzer/metrics.js +120 -0
  203. package/shared/analyzer/pathUtil.js +69 -0
  204. package/shared/analyzer/patterns.js +272 -0
  205. package/shared/analyzer/scan.js +473 -0
  206. package/shared/analyzer/security.js +187 -0
  207. package/shared/analyzer/services.js +157 -0
  208. package/shared/analyzer/stack.js +173 -0
  209. package/shared/analyzer/tour.js +69 -0
  210. package/shared/analyzer/util.js +123 -0
  211. package/shared/analyzer/workflows.js +143 -0
  212. package/shared/diagram/aiFacts.js +145 -0
  213. package/shared/diagram/aiMermaid.js +59 -0
  214. package/shared/diagram/atlas.js +93 -0
  215. package/shared/diagram/mermaid.js +459 -0
  216. package/shared/search/query.js +518 -0
@@ -0,0 +1,213 @@
1
+ // The Streamable HTTP transport (MCP 2025-06-18), on one endpoint that answers
2
+ // POST and GET.
3
+ //
4
+ // This exists because of the button. A stdio server is spawned *by* its client, so
5
+ // a child this app started has nobody to talk to it — starting one from the UI
6
+ // would launch a process no harness could ever reach. Over HTTP the server is a
7
+ // server: the button starts it, a harness connects to it, and stopping it closes
8
+ // the door for everyone. Both transports are offered because they suit different
9
+ // clients, and the spec says clients should support stdio where possible.
10
+ //
11
+ // The security section of the spec is not optional here, and this server has no
12
+ // authentication at all, so the three protections it names are all the protection
13
+ // there is:
14
+ // * Bind 127.0.0.1 only. Never 0.0.0.0 — this process will read any folder it
15
+ // is asked to, and a LAN-reachable copy of that is not something to ship by
16
+ // accident.
17
+ // * Validate `Origin` on every request, so a page on another site cannot drive
18
+ // it on the person's behalf.
19
+ // * Validate `Host`, so a rebinding name cannot resolve here either.
20
+ //
21
+ // What is deliberately *not* implemented: OAuth and `Mcp-Session-Id`. Nothing is
22
+ // held per-connection, so a session id would gate nothing that the localhost bind
23
+ // does not already gate — and pretending to a security model this process does not
24
+ // have would be worse than not claiming one.
25
+
26
+ import http from 'node:http';
27
+
28
+ import { handle, PROTOCOL_VERSION, SERVER_INFO } from './server.js';
29
+ import { toolDefinitions } from './tools.js';
30
+
31
+ export const HTTP_HOST = '127.0.0.1';
32
+ export const DEFAULT_PORT = 8790;
33
+ const MAX_BODY = 4 * 1024 * 1024; // a tools/call argument list, not a file upload
34
+
35
+ // A POST without this in its Accept header is not an MCP client. The spec requires
36
+ // clients to send both, and enforcing it is how a stray browser `fetch` to this
37
+ // port is turned away at the door.
38
+ function acceptsBoth(req) {
39
+ const accept = String(req.headers.accept || '');
40
+ return accept.includes('application/json') && accept.includes('text/event-stream');
41
+ }
42
+
43
+ // The spec's DNS-rebinding defence: the Host header must name a loopback address
44
+ // this server actually bound to. A rebinding attack sends `Host: evil.com` with
45
+ // the DNS pointing at 127.0.0.1, so the name is the thing to check, not the
46
+ // resolved address.
47
+ function hostAllowed(hostHeader) {
48
+ if (!hostHeader) return false;
49
+ const host = String(hostHeader).replace(/:\d+$/, '');
50
+ return host === '127.0.0.1' || host === 'localhost' || host === '[::1]' || host === '::1';
51
+ }
52
+
53
+ // Origin, when present, must also be loopback. A request with no Origin at all is
54
+ // a non-browser client — a harness, curl, a test — and those are the intended
55
+ // callers, so their absence is not suspicious.
56
+ function originAllowed(req) {
57
+ const origin = req.headers.origin;
58
+ if (!origin) return true;
59
+ try {
60
+ const { hostname } = new URL(String(origin));
61
+ return hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '[::1]';
62
+ } catch {
63
+ return false;
64
+ }
65
+ }
66
+
67
+ function guard(req, res) {
68
+ if (!hostAllowed(req.headers.host)) {
69
+ sendJSON(res, 421, { error: 'This endpoint is served on localhost only.' });
70
+ return false;
71
+ }
72
+ if (!originAllowed(req)) {
73
+ sendJSON(res, 403, { error: 'Cross-origin requests are not accepted.' });
74
+ return false;
75
+ }
76
+ return true;
77
+ }
78
+
79
+ function sendJSON(res, status, value, headers = {}) {
80
+ const body = JSON.stringify(value);
81
+ res.writeHead(status, {
82
+ 'content-type': 'application/json',
83
+ 'content-length': Buffer.byteLength(body),
84
+ ...headers,
85
+ });
86
+ res.end(body);
87
+ }
88
+
89
+ // The 2025-06-18 spec lets a server answer a request with a plain JSON body when
90
+ // it has nothing server-initiated to send. Sending both JSON and an event stream
91
+ // would make a client that honours `application/json` wait forever for a stream
92
+ // that never comes.
93
+ function sendStreamable(res, message) {
94
+ const body = JSON.stringify(message);
95
+ res.writeHead(200, {
96
+ 'content-type': 'application/json',
97
+ 'content-length': Buffer.byteLength(body),
98
+ });
99
+ res.end(body);
100
+ }
101
+
102
+ function readBody(req) {
103
+ return new Promise((resolve, reject) => {
104
+ const chunks = [];
105
+ let size = 0;
106
+ req.on('data', (chunk) => {
107
+ size += chunk.length;
108
+ if (size > MAX_BODY) {
109
+ reject(new Error('Request body too large.'));
110
+ req.destroy();
111
+ return;
112
+ }
113
+ chunks.push(chunk);
114
+ });
115
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
116
+ req.on('error', reject);
117
+ });
118
+ }
119
+
120
+ async function onPost(req, res) {
121
+ if (!acceptsBoth(req)) {
122
+ return sendJSON(res, 406, {
123
+ error: 'An MCP client must Accept: application/json and text/event-stream.',
124
+ });
125
+ }
126
+
127
+ let msg;
128
+ try {
129
+ msg = JSON.parse(await readBody(req));
130
+ } catch (err) {
131
+ return sendJSON(res, 400, {
132
+ jsonrpc: '2.0', id: null,
133
+ error: { code: -32700, message: `Could not parse the request: ${err.message}` },
134
+ });
135
+ }
136
+
137
+ // A batch is legal JSON-RPC, and answering it in order keeps the client simple.
138
+ // Notifications inside it produce no entries, which is correct: `handle` returns
139
+ // undefined for them and they are filtered out here.
140
+ if (Array.isArray(msg)) {
141
+ const replies = [];
142
+ for (const one of msg) {
143
+ const reply = await handle(one);
144
+ if (reply) replies.push(reply);
145
+ }
146
+ if (!replies.length) {
147
+ res.writeHead(202).end();
148
+ return undefined;
149
+ }
150
+ return sendStreamable(res, replies);
151
+ }
152
+
153
+ const reply = await handle(msg);
154
+ // A lone notification: the spec says answer 202 with no body.
155
+ if (!reply) {
156
+ res.writeHead(202).end();
157
+ return undefined;
158
+ }
159
+ return sendStreamable(res, reply);
160
+ }
161
+
162
+ function onGet(res) {
163
+ // Without SSE there is no stream to open. 405 is the honest answer: this server
164
+ // does not offer the GET half of the transport, and a client that wants
165
+ // server-initiated messages should have used stdio.
166
+ res.writeHead(405, { allow: 'POST', 'content-type': 'application/json' });
167
+ res.end(JSON.stringify({
168
+ error: 'This server does not offer server-initiated streams. Use the stdio transport, or POST to this endpoint.',
169
+ }));
170
+ }
171
+
172
+ export function handleMcpHttpRequest(req, res) {
173
+ if (!guard(req, res)) return undefined;
174
+ if (req.method === 'POST') return onPost(req, res);
175
+ if (req.method === 'GET') return onGet(res);
176
+ if (req.method === 'DELETE') {
177
+ // No sessions exist, so there is nothing to delete. Saying so keeps a client
178
+ // that cleans up politely from logging an error on the way out.
179
+ res.writeHead(200, { 'content-type': 'application/json' });
180
+ res.end(JSON.stringify({ ok: true }));
181
+ return undefined;
182
+ }
183
+ res.writeHead(405, { allow: 'POST, GET, DELETE' }).end();
184
+ return undefined;
185
+ }
186
+
187
+ /**
188
+ * Start the Streamable HTTP transport. Returns `{ url, port, server, close }`, and
189
+ * `port` is the port actually bound — which matters because the caller may have
190
+ * asked for 0 and gets a free one back.
191
+ */
192
+ export function createHttpTransport({ port = DEFAULT_PORT, host = HTTP_HOST } = {}) {
193
+ const server = http.createServer(handleMcpHttpRequest);
194
+ // A long tool call is a scan of a large repository. The default 5s headers
195
+ // timeout would cut the request off before the body finished arriving; the
196
+ // request timeout covers the whole exchange and is generous on purpose, since
197
+ // the person who asked for it is waiting for it.
198
+ server.headersTimeout = 30_000;
199
+ server.requestTimeout = 600_000;
200
+
201
+ return new Promise((resolve, reject) => {
202
+ server.once('error', reject);
203
+ server.listen(port, host, () => {
204
+ const actual = server.address().port;
205
+ resolve({
206
+ url: `http://${host}:${actual}/mcp`,
207
+ port: actual,
208
+ server,
209
+ close: () => new Promise((done) => server.close(done)),
210
+ });
211
+ });
212
+ });
213
+ }
@@ -0,0 +1,278 @@
1
+ // The process runner. The HTTP server owns one of these; the topbar button calls
2
+ // `start` and `stop` on it.
3
+ //
4
+ // Why a child process rather than an HTTP endpoint: the MCP stdio transport *is*
5
+ // the contract. A client config points at a command and speaks JSON-RPC to its
6
+ // stdin/stdout. There is no port to connect to, and nothing to authenticate,
7
+ // which is why this transport is the right one for a local-first tool and why it
8
+ // must stay local if it is ever exposed at all.
9
+ //
10
+ // The child is deliberately plain: `node server/mcp/standalone.js`. No `npx`, no
11
+ // shell, no user-supplied arguments — the one thing this runner executes is a
12
+ // path built from this repository's own directory, so there is no injection
13
+ // surface here even though the tool server can read any folder it is pointed at.
14
+
15
+ import { spawn } from 'node:child_process';
16
+ import path from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ import { mcpStatus } from './tools.js';
20
+
21
+ const here = path.dirname(fileURLToPath(import.meta.url));
22
+ // server/mcp/ -> server/ -> repository root
23
+ export const REPO_ROOT = path.resolve(here, '..', '..');
24
+ export const ENTRY = path.join(here, 'standalone.js');
25
+
26
+ // How long to wait for a graceful stop before killing. SIGTERM lets Node finish
27
+ // what it is doing; a hard kill would be an acceptable fallback, but only after
28
+ // this long, because a scan in progress deserves the chance to finish.
29
+ const STOP_GRACE_MS = 1500;
30
+
31
+ // The command a client is configured with, and the only one this file will ever
32
+ // run. It is a function rather than a constant string in a template because a
33
+ // client config is a promise about what is actually happening: the path in the
34
+ // README, the path in the copy box, and the argv the runner builds all have to be
35
+ // the same string, and this is the one place it is written.
36
+ export function mcpCommand() {
37
+ return path.relative(REPO_ROOT, ENTRY);
38
+ }
39
+
40
+ // The absolute form, for anything a client will execute.
41
+ //
42
+ // The runner can launch `server/mcp/standalone.js` relative to `cwd` because it
43
+ // sets `cwd` itself. A client cannot: it spawns the command wherever the user's
44
+ // harness happens to live, so a relative path in a copied config is a command
45
+ // that fails only after it has been pasted somewhere real. The copy box and the
46
+ // README therefore both use this, and only the runner gets the relative one.
47
+ export function mcpEntryPoint() {
48
+ return ENTRY;
49
+ }
50
+
51
+ //
52
+ // `override` exists so a test can substitute a command — one that exits at once,
53
+ // one that answers the handshake wrong — without the production path growing a
54
+ // test-only branch. It is the whole argument or nothing: a half-overridden config
55
+ // would spawn something that is neither the real server nor the fake.
56
+
57
+ export function mcpConfig(override) {
58
+ if (override) {
59
+ return {
60
+ command: override.command,
61
+ args: override.args || [],
62
+ cwd: override.cwd || REPO_ROOT,
63
+ };
64
+ }
65
+ return {
66
+ command: process.execPath,
67
+ args: [ENTRY],
68
+ cwd: REPO_ROOT,
69
+ };
70
+ }
71
+
72
+ export class McpRunner {
73
+ get running() {
74
+ return this.state === 'running' || this.state === 'starting';
75
+ }
76
+
77
+ note(line) {
78
+ if (!line) return;
79
+ const entry = { at: Date.now(), line };
80
+ this.log.push(entry);
81
+ // Bounded, because this is a status panel, not a log file.
82
+ if (this.log.length > 100) this.log.shift();
83
+ this.onLog(entry);
84
+ }
85
+
86
+ // `command`/`args`/`cwd` are the whole argv override, handed straight to
87
+ // `mcpConfig`. Accepting them flat rather than as one nested object is what
88
+ // every caller naturally reaches for — `new McpRunner({ command, args })` — and
89
+ // the tests need it to point the runner at a fake child. The runner itself has
90
+ // no idea what it is launching: a runner that knew would be a second place
91
+ // where the entry point is written down, and the two could drift.
92
+ constructor({ onLog, command, args, cwd } = {}) {
93
+ this.commandOverride = command
94
+ ? { command, args: args || [], ...(cwd ? { cwd } : {}) }
95
+ : null;
96
+ this.child = null;
97
+ this.state = 'stopped'; // stopped | starting | running | stopping
98
+ this.startedAt = null;
99
+ this.lastError = null;
100
+ // The tail of the child's output, kept for the UI. An MCP child that dies
101
+ // usually says why just before it goes, and "it stopped" with no reason is
102
+ // the single most annoying thing a status panel can tell you.
103
+ this.log = [];
104
+ this.onLog = onLog || (() => {});
105
+ }
106
+
107
+ async start() {
108
+ if (this.running) return this.status();
109
+ this.state = 'starting';
110
+ this.lastError = null;
111
+ this.log = [];
112
+
113
+ // Built from the same function the config box shows. If those could differ,
114
+ // the button would be reporting on a different server than the one a client
115
+ // launches — which is worse than having no button.
116
+ const cfg = mcpConfig(this.commandOverride);
117
+ const child = spawn(cfg.command, cfg.args, {
118
+ cwd: cfg.cwd,
119
+ stdio: ['pipe', 'pipe', 'pipe'],
120
+ env: { ...process.env, ONBOARDER_MCP: '1', LOG_LEVEL: 'warn' },
121
+ // No shell: the command is a fixed argv, and a shell would only add a way
122
+ // for something to be interpreted.
123
+ shell: false,
124
+ });
125
+ this.child = child;
126
+
127
+ child.stdout.setEncoding('utf8');
128
+ child.stderr.setEncoding('utf8');
129
+ // Only stderr is a log. stdout is the MCP protocol stream: logging it would
130
+ // fill the panel with JSON-RPC frames, and the one that matters — the
131
+ // handshake reply — is consumed by `handshake` below. The stream is drained
132
+ // regardless, because a pipe nobody reads eventually blocks the writer.
133
+ child.stdout.on('data', () => {});
134
+ child.stderr.on('data', (chunk) => this.note(String(chunk).trimEnd()));
135
+
136
+ const exited = new Promise((resolve) => {
137
+ child.once('exit', (code, signal) => {
138
+ const wasStopping = this.state === 'stopping';
139
+ this.child = null;
140
+ this.state = 'stopped';
141
+ this.startedAt = null;
142
+ if (!wasStopping) {
143
+ this.lastError = signal
144
+ ? `Stopped unexpectedly (${signal}).`
145
+ : `Stopped unexpectedly (exit code ${code}).`;
146
+ this.note(this.lastError);
147
+ }
148
+ resolve({ code, signal });
149
+ });
150
+ });
151
+
152
+ child.once('error', (err) => {
153
+ this.lastError = `Could not start: ${err.message}`;
154
+ this.state = 'stopped';
155
+ this.child = null;
156
+ this.note(this.lastError);
157
+ });
158
+
159
+ // A handshake proves the server is not merely spawned but actually answering
160
+ // MCP. "running" that means the process exists is how a broken server ends up
161
+ // looking healthy in the UI.
162
+ const ok = await this.handshake(exited);
163
+ if (ok) {
164
+ this.state = 'running';
165
+ this.startedAt = Date.now();
166
+ this.note('MCP server ready on stdio.');
167
+ } else if (this.child) {
168
+ await this.stop();
169
+ } else {
170
+ this.state = 'stopped';
171
+ }
172
+ return this.status();
173
+ }
174
+
175
+ // The MCP handshake, spoken to our own child: initialize, then the
176
+ // `notifications/initialized` the spec requires before ordinary traffic.
177
+ async handshake(exited) {
178
+ const child = this.child;
179
+ if (!child) return false;
180
+
181
+ return new Promise((resolve) => {
182
+ let buffer = '';
183
+ let settled = false;
184
+ const finish = (ok) => {
185
+ if (settled) return;
186
+ settled = true;
187
+ clearTimeout(timer);
188
+ child.stdout.off('data', onData);
189
+ // Resolve now, not on exit. The child is *supposed* to stay running, so
190
+ // waiting for it to exit would mean this promise only ever settled when
191
+ // something had already gone wrong.
192
+ resolve(ok && this.child !== null);
193
+ };
194
+ const onData = (chunk) => {
195
+ buffer += chunk;
196
+ let nl;
197
+ while ((nl = buffer.indexOf('\n')) !== -1) {
198
+ const line = buffer.slice(0, nl).trim();
199
+ buffer = buffer.slice(nl + 1);
200
+ if (!line) continue;
201
+ try {
202
+ const msg = JSON.parse(line);
203
+ if (msg?.id === 'onboarder-probe' && msg.result?.serverInfo?.name === 'onboarder') {
204
+ child.stdin.write(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n');
205
+ finish(true);
206
+ return;
207
+ }
208
+ } catch {
209
+ // A line that is not JSON during the handshake is a bug elsewhere —
210
+ // something wrote to stdout that should not have. Keep reading: the
211
+ // real initialize reply may still arrive.
212
+ }
213
+ }
214
+ };
215
+
216
+ const timer = setTimeout(() => {
217
+ this.lastError = 'The MCP server did not answer its handshake within 5s.';
218
+ this.note(this.lastError);
219
+ finish(false);
220
+ }, 5000);
221
+
222
+ child.stdout.on('data', onData);
223
+ // If the child dies first, do not wait out the full timeout.
224
+ exited.then(() => finish(false));
225
+
226
+ child.stdin.write(JSON.stringify({
227
+ jsonrpc: '2.0',
228
+ id: 'onboarder-probe',
229
+ method: 'initialize',
230
+ params: {
231
+ protocolVersion: '2025-06-18',
232
+ capabilities: {},
233
+ clientInfo: { name: 'onboarder-ui', version: '1.0.0' },
234
+ },
235
+ }) + '\n');
236
+ });
237
+ }
238
+
239
+ async stop() {
240
+ const child = this.child;
241
+ if (!child) {
242
+ this.state = 'stopped';
243
+ return this.status();
244
+ }
245
+ this.state = 'stopping';
246
+
247
+ // Closing stdin is the polite shutdown: `serveMcp` drains what is queued and
248
+ // lets the loop empty, so a request in flight gets its answer.
249
+ const exited = new Promise((resolve) => child.once('exit', resolve));
250
+ child.stdin.end();
251
+ const timer = setTimeout(() => child.kill('SIGKILL'), STOP_GRACE_MS);
252
+
253
+ await exited;
254
+ clearTimeout(timer);
255
+ this.child = null;
256
+ this.state = 'stopped';
257
+ this.startedAt = null;
258
+ this.note('MCP server stopped.');
259
+ return this.status();
260
+ }
261
+
262
+ status() {
263
+ return {
264
+ ...mcpStatus(),
265
+ state: this.state,
266
+ running: this.running,
267
+ pid: this.child?.pid ?? null,
268
+ startedAt: this.startedAt,
269
+ uptimeMs: this.startedAt ? Date.now() - this.startedAt : 0,
270
+ lastError: this.lastError,
271
+ log: this.log.slice(-20),
272
+ };
273
+ }
274
+ }
275
+
276
+ export function createMcpRunner(opts) {
277
+ return new McpRunner(opts);
278
+ }