devtorch-core 3.0.1__py3-none-any.whl

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 (193) hide show
  1. devtorch_core/__init__.py +158 -0
  2. devtorch_core/aggphi_textual.py +275 -0
  3. devtorch_core/alerts/__init__.py +23 -0
  4. devtorch_core/alerts/base.py +46 -0
  5. devtorch_core/alerts/config.py +60 -0
  6. devtorch_core/alerts/dispatcher.py +110 -0
  7. devtorch_core/alerts/jira.py +96 -0
  8. devtorch_core/alerts/linear.py +72 -0
  9. devtorch_core/alerts/pagerduty.py +66 -0
  10. devtorch_core/alerts/slack.py +81 -0
  11. devtorch_core/alerts/teams.py +70 -0
  12. devtorch_core/audit/__init__.py +43 -0
  13. devtorch_core/audit/exporter.py +297 -0
  14. devtorch_core/audit/privacy.py +101 -0
  15. devtorch_core/audit/scrubber.py +149 -0
  16. devtorch_core/audit/service.py +67 -0
  17. devtorch_core/audit/signing.py +127 -0
  18. devtorch_core/broadcast/__init__.py +4 -0
  19. devtorch_core/broadcast/broadcaster.py +100 -0
  20. devtorch_core/broadcast/watcher.py +71 -0
  21. devtorch_core/capability.py +639 -0
  22. devtorch_core/cloud/__init__.py +1 -0
  23. devtorch_core/cloud/client_config.py +472 -0
  24. devtorch_core/cloud/client_configs/.claude-opencode-fallback.json +8 -0
  25. devtorch_core/cloud/client_configs/.claude-stdio.json +13 -0
  26. devtorch_core/cloud/client_configs/.cursor-mcp.json +13 -0
  27. devtorch_core/cloud/client_configs/.opencode-bridge.json +13 -0
  28. devtorch_core/cloud/client_configs/.opencode.json +15 -0
  29. devtorch_core/cloud/client_configs/.vscode-mcp.json +13 -0
  30. devtorch_core/cloud/devtorch-mcp-bridge.js +357 -0
  31. devtorch_core/cloud/mcp_client.py +229 -0
  32. devtorch_core/cloud/setup.py +144 -0
  33. devtorch_core/cloud/sync.py +143 -0
  34. devtorch_core/cloud/sync_bundle.py +603 -0
  35. devtorch_core/cloud/sync_conflicts.py +159 -0
  36. devtorch_core/cloud/sync_state.py +159 -0
  37. devtorch_core/cloud/team_sync.py +283 -0
  38. devtorch_core/codex/__init__.py +9 -0
  39. devtorch_core/codex/__main__.py +97 -0
  40. devtorch_core/codex/capture.py +208 -0
  41. devtorch_core/codex/proxy.py +412 -0
  42. devtorch_core/concept_catalog.py +209 -0
  43. devtorch_core/consolidation/__init__.py +3 -0
  44. devtorch_core/consolidation/synthesizer.py +87 -0
  45. devtorch_core/consolidation/workflow.py +175 -0
  46. devtorch_core/daemon/__init__.py +27 -0
  47. devtorch_core/daemon/supervisor.py +293 -0
  48. devtorch_core/daemon/watcher.py +244 -0
  49. devtorch_core/dashboard_api.py +2012 -0
  50. devtorch_core/deltaf.py +97 -0
  51. devtorch_core/disclosure.py +50 -0
  52. devtorch_core/divergence/__init__.py +3 -0
  53. devtorch_core/divergence/detector.py +166 -0
  54. devtorch_core/gateway/__init__.py +32 -0
  55. devtorch_core/gateway/key_manager.py +124 -0
  56. devtorch_core/gateway/metrics_webhook.py +252 -0
  57. devtorch_core/gateway/policy.py +262 -0
  58. devtorch_core/gateway/server.py +727 -0
  59. devtorch_core/gateway/sso.py +233 -0
  60. devtorch_core/gcc.py +1246 -0
  61. devtorch_core/github/__init__.py +35 -0
  62. devtorch_core/github/app.py +240 -0
  63. devtorch_core/github/comment_builder.py +113 -0
  64. devtorch_core/github/pat.py +76 -0
  65. devtorch_core/github/pr_parser.py +82 -0
  66. devtorch_core/github/pr_reporter.py +555 -0
  67. devtorch_core/gitlab/__init__.py +177 -0
  68. devtorch_core/hitl/__init__.py +4 -0
  69. devtorch_core/hitl/channels.py +129 -0
  70. devtorch_core/hitl/orchestrator.py +95 -0
  71. devtorch_core/hooks/__init__.py +17 -0
  72. devtorch_core/hooks/claude_code.py +228 -0
  73. devtorch_core/hooks/git_capture.py +341 -0
  74. devtorch_core/hooks/git_commit.py +182 -0
  75. devtorch_core/hooks/installer.py +733 -0
  76. devtorch_core/hooks/pre_commit.py +157 -0
  77. devtorch_core/hooks/runner.py +344 -0
  78. devtorch_core/identity/__init__.py +4 -0
  79. devtorch_core/identity/agent.py +86 -0
  80. devtorch_core/identity/providers.py +85 -0
  81. devtorch_core/invariants.py +182 -0
  82. devtorch_core/mcp/__init__.py +10 -0
  83. devtorch_core/mcp/auth.py +177 -0
  84. devtorch_core/mcp/server.py +1049 -0
  85. devtorch_core/metrics/__init__.py +35 -0
  86. devtorch_core/metrics/aggregate.py +215 -0
  87. devtorch_core/metrics/calibrate.py +198 -0
  88. devtorch_core/metrics/calibration.py +125 -0
  89. devtorch_core/metrics/credibility.py +288 -0
  90. devtorch_core/metrics/delivery_time.py +70 -0
  91. devtorch_core/metrics/dhs.py +126 -0
  92. devtorch_core/metrics/mcs.py +96 -0
  93. devtorch_core/metrics/roi.py +88 -0
  94. devtorch_core/metrics/session_writer.py +81 -0
  95. devtorch_core/metrics/shadow_ai.py +117 -0
  96. devtorch_core/metrics/sprint_writer.py +243 -0
  97. devtorch_core/observability/__init__.py +78 -0
  98. devtorch_core/observability/datadog.py +157 -0
  99. devtorch_core/observability/formatter.py +119 -0
  100. devtorch_core/observability/report.py +264 -0
  101. devtorch_core/observability/servicenow.py +147 -0
  102. devtorch_core/observability/splunk.py +218 -0
  103. devtorch_core/observability/webhook.py +227 -0
  104. devtorch_core/parser/__init__.py +30 -0
  105. devtorch_core/parser/blocks.py +216 -0
  106. devtorch_core/parser/inference.py +159 -0
  107. devtorch_core/parser/thinking.py +112 -0
  108. devtorch_core/projects.py +169 -0
  109. devtorch_core/prompt_artifact.py +76 -0
  110. devtorch_core/proxy/__init__.py +9 -0
  111. devtorch_core/proxy/routes/__init__.py +1 -0
  112. devtorch_core/proxy/routes/anthropic.py +264 -0
  113. devtorch_core/proxy/routes/azure_openai.py +336 -0
  114. devtorch_core/proxy/routes/gemini.py +331 -0
  115. devtorch_core/proxy/routes/groq.py +284 -0
  116. devtorch_core/proxy/routes/ollama.py +279 -0
  117. devtorch_core/proxy/routes/openai.py +287 -0
  118. devtorch_core/proxy/server.py +356 -0
  119. devtorch_core/query/__init__.py +15 -0
  120. devtorch_core/query/grep.py +181 -0
  121. devtorch_core/query/hybrid.py +86 -0
  122. devtorch_core/query/semantic.py +157 -0
  123. devtorch_core/rdp.py +105 -0
  124. devtorch_core/reasoning/__init__.py +4 -0
  125. devtorch_core/reasoning/entry.py +31 -0
  126. devtorch_core/reasoning/store.py +122 -0
  127. devtorch_core/reasoning_plus/__init__.py +70 -0
  128. devtorch_core/reasoning_plus/augmenter.py +326 -0
  129. devtorch_core/reasoning_plus/capture.py +51 -0
  130. devtorch_core/reasoning_plus/config.py +256 -0
  131. devtorch_core/reasoning_plus/context.py +262 -0
  132. devtorch_core/reasoning_plus/learning/__init__.py +72 -0
  133. devtorch_core/reasoning_plus/learning/analytics.py +141 -0
  134. devtorch_core/reasoning_plus/learning/api.py +313 -0
  135. devtorch_core/reasoning_plus/learning/chain.py +285 -0
  136. devtorch_core/reasoning_plus/learning/composer.py +74 -0
  137. devtorch_core/reasoning_plus/learning/cross_project.py +234 -0
  138. devtorch_core/reasoning_plus/learning/embeddings.py +209 -0
  139. devtorch_core/reasoning_plus/learning/extractor.py +207 -0
  140. devtorch_core/reasoning_plus/learning/models.py +116 -0
  141. devtorch_core/reasoning_plus/learning/provenance.py +126 -0
  142. devtorch_core/reasoning_plus/learning/recorder.py +81 -0
  143. devtorch_core/reasoning_plus/learning/relevance.py +122 -0
  144. devtorch_core/reasoning_plus/learning/state.py +86 -0
  145. devtorch_core/reasoning_plus/learning/store.py +160 -0
  146. devtorch_core/reasoning_plus/learning/theta_learning_bridge.py +94 -0
  147. devtorch_core/reasoning_plus/prompt.py +90 -0
  148. devtorch_core/rep.py +134 -0
  149. devtorch_core/rep_network/__init__.py +25 -0
  150. devtorch_core/rep_network/merge.py +70 -0
  151. devtorch_core/rep_network/node.py +137 -0
  152. devtorch_core/rep_network/server.py +140 -0
  153. devtorch_core/rep_network/sync.py +207 -0
  154. devtorch_core/sensitivity.py +182 -0
  155. devtorch_core/serve.py +258 -0
  156. devtorch_core/session/__init__.py +39 -0
  157. devtorch_core/session/disagreement.py +188 -0
  158. devtorch_core/session/models.py +114 -0
  159. devtorch_core/session/orchestrator.py +182 -0
  160. devtorch_core/session/planner.py +169 -0
  161. devtorch_core/session/simulator.py +132 -0
  162. devtorch_core/signing.py +290 -0
  163. devtorch_core/sis.py +197 -0
  164. devtorch_core/storage.py +308 -0
  165. devtorch_core/templates/__init__.py +6 -0
  166. devtorch_core/templates/engine.py +122 -0
  167. devtorch_core/templates/go.py +18 -0
  168. devtorch_core/templates/infra.py +19 -0
  169. devtorch_core/templates/library/__init__.py +18 -0
  170. devtorch_core/templates/library/api_design.md +27 -0
  171. devtorch_core/templates/library/bug_fix.md +27 -0
  172. devtorch_core/templates/library/decision_record.md +27 -0
  173. devtorch_core/templates/library/engine.py +228 -0
  174. devtorch_core/templates/library/security_review.md +30 -0
  175. devtorch_core/templates/python.py +19 -0
  176. devtorch_core/templates/react.py +18 -0
  177. devtorch_core/templates/typescript.py +18 -0
  178. devtorch_core/theta.py +221 -0
  179. devtorch_core/theta_synthesis.py +268 -0
  180. devtorch_core/topics.py +320 -0
  181. devtorch_core/variance.py +219 -0
  182. devtorch_core/wrapper/__init__.py +52 -0
  183. devtorch_core/wrapper/anthropic.py +487 -0
  184. devtorch_core/wrapper/base.py +562 -0
  185. devtorch_core/wrapper/bedrock.py +342 -0
  186. devtorch_core/wrapper/gemini.py +422 -0
  187. devtorch_core/wrapper/ollama.py +527 -0
  188. devtorch_core/wrapper/openai.py +461 -0
  189. devtorch_core-3.0.1.dist-info/METADATA +867 -0
  190. devtorch_core-3.0.1.dist-info/RECORD +193 -0
  191. devtorch_core-3.0.1.dist-info/WHEEL +5 -0
  192. devtorch_core-3.0.1.dist-info/entry_points.txt +2 -0
  193. devtorch_core-3.0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,357 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * DevTorch MCP stdio ↔ SSE bridge.
4
+ *
5
+ * Allows any stdio-only MCP client (Claude Code, VS Code, Cursor, OpenCode in
6
+ * stdio/local mode) to connect to the Cloudflare (or any) DevTorch SSE MCP
7
+ * server.
8
+ *
9
+ * Resilience: for the DevTorch write tools (`devtorch_commit` and
10
+ * `devtorch_sensitivity_add`), the bridge will automatically fall back to the
11
+ * local `devtorch` CLI if the cloud server returns an error or does not answer
12
+ * within the configured timeout. This keeps the local `.GCC/` audit trail
13
+ * intact even when the cloud endpoint is unreachable or slow.
14
+ *
15
+ * Usage:
16
+ * DEVTORCH_MCP_URL=https://devtorch-mcp.example.com/myorg/myrepo/sse \
17
+ * DEVTORCH_API_KEY=... \
18
+ * node devtorch-mcp-bridge.js
19
+ *
20
+ * Optional:
21
+ * DEVTORCH_MCP_FALLBACK_TIMEOUT_MS=3000 (default: 8000)
22
+ *
23
+ * Then configure the IDE with:
24
+ * { "type": "local", "command": ["node", "/path/to/devtorch-mcp-bridge.js"] }
25
+ */
26
+
27
+ const { spawn } = require("child_process");
28
+ const { TextDecoder } = require("util");
29
+
30
+ const SSE_URL = process.env.DEVTORCH_MCP_URL;
31
+ const API_KEY = process.env.DEVTORCH_API_KEY;
32
+ const FALLBACK_TIMEOUT_MS = parseInt(
33
+ process.env.DEVTORCH_MCP_FALLBACK_TIMEOUT_MS || "8000",
34
+ 10
35
+ );
36
+ const FALLBACK_TOOLS = new Set(["devtorch_commit", "devtorch_sensitivity_add"]);
37
+
38
+ if (!SSE_URL || !API_KEY) {
39
+ console.error("Missing DEVTORCH_MCP_URL or DEVTORCH_API_KEY");
40
+ process.exit(1);
41
+ }
42
+
43
+ let messageUrl = null;
44
+ let sessionId = null;
45
+
46
+ // Requests we are waiting on and that require transparent local fallback.
47
+ const pending = new Map(); // id -> { resolve, reject, timer, req }
48
+
49
+ // Requests that were already handled locally (fallback or late response).
50
+ // We keep this small set so a late cloud response does not leak to the client.
51
+ const handledIds = new Set();
52
+
53
+ async function main() {
54
+ const headers = { "X-DevTorch-Key": API_KEY };
55
+
56
+ const response = await fetch(SSE_URL, { headers });
57
+ if (!response.ok) {
58
+ console.error(
59
+ `SSE connection failed: ${response.status} ${response.statusText}`
60
+ );
61
+ process.exit(1);
62
+ }
63
+
64
+ const reader = response.body.getReader();
65
+ const decoder = new TextDecoder();
66
+ let buffer = "";
67
+
68
+ // Read incoming SSE events and either resolve a pending fallback request or
69
+ // forward the JSON-RPC payload to stdout.
70
+ (async () => {
71
+ while (true) {
72
+ const { done, value } = await reader.read();
73
+ if (done) break;
74
+ buffer += decoder.decode(value, { stream: true });
75
+ const lines = buffer.split("\n\n");
76
+ buffer = lines.pop() || "";
77
+ for (const chunk of lines) {
78
+ const event = parseEvent(chunk);
79
+ if (!event) continue;
80
+ if (event.type === "endpoint") {
81
+ messageUrl = event.data;
82
+ const url = new URL(messageUrl);
83
+ sessionId = url.searchParams.get("sessionId");
84
+ } else if (event.type === "message") {
85
+ handleMessage(event.data);
86
+ }
87
+ }
88
+ }
89
+ })();
90
+
91
+ // Read JSON-RPC requests from stdin and POST them to the messages endpoint.
92
+ const stdin = process.stdin;
93
+ stdin.setEncoding("utf8");
94
+ let inBuffer = "";
95
+ stdin.on("data", async (data) => {
96
+ inBuffer += data;
97
+ const lines = inBuffer.split("\n");
98
+ inBuffer = lines.pop() || "";
99
+ for (const line of lines) {
100
+ if (!line.trim()) continue;
101
+ await handleStdinLine(line);
102
+ }
103
+ });
104
+
105
+ stdin.on("end", () => {
106
+ process.exit(0);
107
+ });
108
+ }
109
+
110
+ function handleMessage(data) {
111
+ let json;
112
+ try {
113
+ json = JSON.parse(data);
114
+ } catch (e) {
115
+ process.stdout.write(data + "\n");
116
+ return;
117
+ }
118
+
119
+ const id = json.id;
120
+ if (id !== undefined && handledIds.has(id)) {
121
+ // Late response after we already handled the request locally. Drop it.
122
+ return;
123
+ }
124
+
125
+ if (id !== undefined && pending.has(id)) {
126
+ const p = pending.get(id);
127
+ clearTimeout(p.timer);
128
+ pending.delete(id);
129
+ p.resolve({ data, json });
130
+ return;
131
+ }
132
+
133
+ process.stdout.write(data + "\n");
134
+ }
135
+
136
+ async function handleStdinLine(line) {
137
+ let req;
138
+ try {
139
+ req = JSON.parse(line);
140
+ } catch (e) {
141
+ console.error(`Invalid JSON-RPC request: ${line}`);
142
+ return;
143
+ }
144
+
145
+ if (!messageUrl) {
146
+ console.error("Message endpoint not yet received from SSE");
147
+ return;
148
+ }
149
+
150
+ const toolName = req.method === "tools/call" && req.params && req.params.name;
151
+ if (toolName && FALLBACK_TOOLS.has(toolName)) {
152
+ await handleWithFallback(req, line);
153
+ } else {
154
+ await forwardRequest(line);
155
+ }
156
+ }
157
+
158
+ async function handleWithFallback(req, line) {
159
+ const id = req.id;
160
+ if (id === undefined) {
161
+ // Notification-style tool call; forward without waiting.
162
+ await forwardRequest(line);
163
+ return;
164
+ }
165
+
166
+ // Register the pending entry BEFORE the POST: the server may deliver the
167
+ // SSE response while the POST is still in flight (its 202 only returns
168
+ // after the tool has executed, which can exceed the fallback timeout).
169
+ const responsePromise = new Promise((resolve, reject) => {
170
+ pending.set(id, { resolve, reject, timer: null, req });
171
+ });
172
+ // A timeout rejection must never become an unhandled rejection while we
173
+ // are still awaiting the POST — that kills the whole bridge process and
174
+ // the IDE sees the MCP connection drop (issue #18).
175
+ responsePromise.catch(() => {});
176
+
177
+ try {
178
+ await forwardRequest(line);
179
+ } catch (e) {
180
+ pending.delete(id);
181
+ handledIds.add(id);
182
+ const result = await runLocalFallback(req);
183
+ process.stdout.write(
184
+ JSON.stringify({ jsonrpc: "2.0", id, result }) + "\n"
185
+ );
186
+ return;
187
+ }
188
+
189
+ // The server accepted the request; the SSE response normally follows
190
+ // within moments. Only now start the fallback timer, so it measures
191
+ // response delivery — not tool execution time.
192
+ const entry = pending.get(id);
193
+ if (entry) {
194
+ entry.timer = setTimeout(() => {
195
+ const q = pending.get(id);
196
+ if (q) {
197
+ pending.delete(id);
198
+ handledIds.add(id);
199
+ q.reject(new Error("MCP timeout"));
200
+ }
201
+ }, FALLBACK_TIMEOUT_MS);
202
+ }
203
+
204
+ try {
205
+ const { data, json } = await responsePromise;
206
+ if (json.error) {
207
+ pending.delete(id);
208
+ handledIds.add(id);
209
+ throw new Error(
210
+ `MCP error: ${json.error.message || JSON.stringify(json.error)}`
211
+ );
212
+ }
213
+ pending.delete(id);
214
+ process.stdout.write(data + "\n");
215
+ } catch (e) {
216
+ if (!handledIds.has(id)) {
217
+ handledIds.add(id);
218
+ }
219
+ pending.delete(id);
220
+ const result = await runLocalFallback(req);
221
+ process.stdout.write(
222
+ JSON.stringify({ jsonrpc: "2.0", id, result }) + "\n"
223
+ );
224
+ }
225
+ }
226
+
227
+ async function forwardRequest(line) {
228
+ const res = await fetch(messageUrl, {
229
+ method: "POST",
230
+ headers: {
231
+ "Content-Type": "application/json",
232
+ "X-DevTorch-Key": API_KEY,
233
+ },
234
+ body: line,
235
+ });
236
+ if (!res.ok) {
237
+ throw new Error(`HTTP ${res.status} ${res.statusText}`);
238
+ }
239
+ }
240
+
241
+ async function runLocalFallback(req) {
242
+ const args = (req.params && req.params.arguments) || {};
243
+ const toolName = req.params.name;
244
+ const cwd = process.env.DEVTORCH_GCC_PATH || process.cwd();
245
+
246
+ try {
247
+ const { exitCode, stdout, stderr } = await runLocalCommand(cwd, toolName, args);
248
+ if (exitCode !== 0) {
249
+ return {
250
+ content: [
251
+ {
252
+ type: "text",
253
+ text: `Local fallback failed (${exitCode}): ${stderr || stdout || "unknown error"}`,
254
+ },
255
+ ],
256
+ isError: true,
257
+ };
258
+ }
259
+ return {
260
+ content: [
261
+ {
262
+ type: "text",
263
+ text: stdout.trim() || "Recorded locally",
264
+ },
265
+ ],
266
+ isError: false,
267
+ };
268
+ } catch (e) {
269
+ return {
270
+ content: [
271
+ {
272
+ type: "text",
273
+ text: `Local fallback error: ${e.message}`,
274
+ },
275
+ ],
276
+ isError: true,
277
+ };
278
+ }
279
+ }
280
+
281
+ function runLocalCommand(cwd, toolName, args) {
282
+ return new Promise((resolve) => {
283
+ let cmd;
284
+ let cliArgs;
285
+
286
+ if (toolName === "devtorch_commit") {
287
+ cmd = "devtorch";
288
+ cliArgs = ["commit", "-m", String(args.message || "")];
289
+ // Note: the local CLI derives the branch from git; the optional `branch`
290
+ // parameter in the MCP tool is intentionally ignored for local fallback.
291
+ } else if (toolName === "devtorch_sensitivity_add") {
292
+ cmd = "devtorch";
293
+ const signal = String(args.signal || "");
294
+ cliArgs = [
295
+ "sensitivity",
296
+ "add",
297
+ "--source",
298
+ "mcp-fallback",
299
+ "--concept",
300
+ String(args.concept || ""),
301
+ "--confidence",
302
+ String(args.confidence || 0),
303
+ ];
304
+ if (signal) {
305
+ cliArgs.push("--counterfactuals", signal, "--message", signal);
306
+ }
307
+ const disclosure = String(
308
+ args.disclosure || args.disclosure_level || "PROTECTED"
309
+ ).toUpperCase();
310
+ if (["PUBLIC", "PROTECTED", "PRIVATE"].includes(disclosure)) {
311
+ cliArgs.push("--disclosure", disclosure);
312
+ }
313
+ } else {
314
+ return resolve({
315
+ exitCode: 1,
316
+ stdout: "",
317
+ stderr: `Unknown tool: ${toolName}`,
318
+ });
319
+ }
320
+
321
+ const proc = spawn(cmd, cliArgs, { cwd, env: process.env });
322
+ let stdout = "";
323
+ let stderr = "";
324
+ proc.stdout.on("data", (d) => {
325
+ stdout += d.toString();
326
+ });
327
+ proc.stderr.on("data", (d) => {
328
+ stderr += d.toString();
329
+ });
330
+ proc.on("close", (exitCode) => {
331
+ resolve({ exitCode: exitCode ?? 1, stdout, stderr });
332
+ });
333
+ proc.on("error", (err) => {
334
+ resolve({ exitCode: 1, stdout: "", stderr: err.message });
335
+ });
336
+ });
337
+ }
338
+
339
+ function parseEvent(text) {
340
+ const lines = text.split("\n");
341
+ let type = null;
342
+ let data = null;
343
+ for (const line of lines) {
344
+ if (line.startsWith("event:")) {
345
+ type = line.slice(6).trim();
346
+ } else if (line.startsWith("data:")) {
347
+ data = line.slice(5).trim();
348
+ }
349
+ }
350
+ if (!type || data === null) return null;
351
+ return { type, data };
352
+ }
353
+
354
+ main().catch((e) => {
355
+ console.error(e);
356
+ process.exit(1);
357
+ });
@@ -0,0 +1,229 @@
1
+ """
2
+ devtorch_core.cloud.mcp_client
3
+ ==============================
4
+ Reusable client for calling remote DevTorch MCP tools via JSON-RPC over SSE.
5
+
6
+ The cloud MCP server accepts tool requests via POST to the messages endpoint
7
+ and returns the JSON-RPC results as SSE ``message`` events. This client keeps
8
+ the SSE connection open in a background reader thread, posts requests, and
9
+ matches responses by request ID.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import logging
15
+ import os
16
+ import threading
17
+ import time
18
+ import urllib.request
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ logger = logging.getLogger("devtorch.cloud.mcp_client")
23
+
24
+ _USER_AGENT = "devtorch-mcp-client"
25
+
26
+
27
+ class McpClient:
28
+ """Call remote DevTorch MCP tools via JSON-RPC over SSE.
29
+
30
+ Usage::
31
+
32
+ client = McpClient(url, api_key)
33
+ result = client.call_tool("devtorch_topics_list", {"all": True})
34
+ client.close()
35
+ """
36
+
37
+ def __init__(self, url: str, api_key: str, timeout: float = 10.0) -> None:
38
+ self._sse_url = url
39
+ self._api_key = api_key
40
+ self._timeout = timeout
41
+ self._messages_url: str | None = None
42
+ self._resp: Any | None = None
43
+ self._reader_thread: threading.Thread | None = None
44
+ self._results: dict[int | str, dict] = {}
45
+ self._lock = threading.Lock()
46
+ self._cv = threading.Condition(self._lock)
47
+ self._next_id = 1
48
+ self._closed = False
49
+
50
+ def __enter__(self) -> "McpClient":
51
+ return self
52
+
53
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
54
+ self.close()
55
+
56
+ def call_tool(self, tool_name: str, args: dict | None = None) -> str:
57
+ """Call a remote MCP tool and return the result text.
58
+
59
+ Raises ``RuntimeError`` on network errors, timeouts, or MCP-level errors.
60
+ """
61
+ self._ensure_connected()
62
+ req_id = self._next_id
63
+ self._next_id += 1
64
+
65
+ payload = {
66
+ "jsonrpc": "2.0",
67
+ "id": req_id,
68
+ "method": "tools/call",
69
+ "params": {"name": tool_name, "arguments": args or {}},
70
+ }
71
+
72
+ self._post(payload)
73
+ body = self._wait_for_response(req_id)
74
+
75
+ if body.get("error"):
76
+ raise RuntimeError(f"MCP error: {json.dumps(body['error'])[:300]}")
77
+ if body.get("result", {}).get("isError"):
78
+ raise RuntimeError(f"MCP tool error: {json.dumps(body['result'])[:300]}")
79
+
80
+ content = body.get("result", {}).get("content", [])
81
+ if content and isinstance(content, list):
82
+ return content[0].get("text", "")
83
+ return json.dumps(body.get("result", {}))
84
+
85
+ def is_available(self) -> bool:
86
+ """Quick connectivity check — returns True if the SSE handshake succeeds."""
87
+ try:
88
+ self._ensure_connected()
89
+ return self._messages_url is not None
90
+ except Exception:
91
+ return False
92
+
93
+ def close(self) -> None:
94
+ """Close the SSE connection and stop the reader thread."""
95
+ self._closed = True
96
+ resp = self._resp
97
+ self._resp = None
98
+ if resp is not None:
99
+ try:
100
+ resp.close()
101
+ except Exception:
102
+ pass
103
+ with self._cv:
104
+ self._cv.notify_all()
105
+ reader = self._reader_thread
106
+ self._reader_thread = None
107
+ if reader is not None and reader.is_alive():
108
+ reader.join(timeout=2.0)
109
+
110
+ def _ensure_connected(self) -> None:
111
+ """Open the SSE connection and start the reader thread if needed."""
112
+ if self._closed:
113
+ raise RuntimeError("McpClient is closed")
114
+ if self._resp is not None and self._messages_url is not None:
115
+ return
116
+
117
+ req = urllib.request.Request(
118
+ self._sse_url,
119
+ headers={
120
+ "Accept": "text/event-stream",
121
+ "X-DevTorch-Key": self._api_key,
122
+ "User-Agent": _USER_AGENT,
123
+ },
124
+ )
125
+ self._resp = urllib.request.urlopen(req, timeout=self._timeout)
126
+ self._reader_thread = threading.Thread(target=self._read_loop, daemon=True)
127
+ self._reader_thread.start()
128
+
129
+ # Wait for the endpoint event to set the messages URL.
130
+ deadline = time.time() + self._timeout
131
+ with self._cv:
132
+ while self._messages_url is None:
133
+ remaining = deadline - time.time()
134
+ if remaining <= 0:
135
+ raise RuntimeError("SSE endpoint event not received in time")
136
+ self._cv.wait(remaining)
137
+
138
+ def _read_loop(self) -> None:
139
+ """Background thread: parse SSE events and store message responses."""
140
+ event_type: str | None = None
141
+ data: str | None = None
142
+ try:
143
+ while self._resp is not None:
144
+ line = self._resp.readline()
145
+ if not line:
146
+ break
147
+ line = line.decode("utf-8", errors="replace")
148
+ if line.startswith("event:"):
149
+ event_type = line[6:].strip()
150
+ elif line.startswith("data:"):
151
+ data = line[5:].strip()
152
+ elif line.strip() == "":
153
+ self._handle_event(event_type, data)
154
+ event_type = None
155
+ data = None
156
+ except Exception as exc:
157
+ logger.debug("SSE read loop ended: %s", exc)
158
+ finally:
159
+ self._resp = None
160
+ self._messages_url = None
161
+
162
+ def _handle_event(self, event_type: str | None, data: str | None) -> None:
163
+ """Process a single SSE event."""
164
+ if event_type == "endpoint" and data:
165
+ with self._cv:
166
+ self._messages_url = data
167
+ self._cv.notify_all()
168
+ elif event_type == "message" and data:
169
+ try:
170
+ body = json.loads(data)
171
+ except json.JSONDecodeError:
172
+ return
173
+ req_id = body.get("id")
174
+ if req_id is None:
175
+ return
176
+ with self._cv:
177
+ self._results[req_id] = body
178
+ self._cv.notify_all()
179
+
180
+ def _post(self, payload: dict) -> None:
181
+ """POST a JSON-RPC request to the messages endpoint."""
182
+ if self._messages_url is None:
183
+ raise RuntimeError("Message endpoint not available")
184
+ req = urllib.request.Request(
185
+ self._messages_url,
186
+ data=json.dumps(payload).encode(),
187
+ headers={
188
+ "Content-Type": "application/json",
189
+ "X-DevTorch-Key": self._api_key,
190
+ "User-Agent": _USER_AGENT,
191
+ },
192
+ )
193
+ with urllib.request.urlopen(req, timeout=self._timeout) as resp:
194
+ # The server returns 202 Accepted; the actual response arrives via SSE.
195
+ pass
196
+
197
+ def _wait_for_response(self, req_id: int) -> dict:
198
+ """Wait for the JSON-RPC response matching ``req_id``."""
199
+ deadline = time.time() + self._timeout
200
+ with self._cv:
201
+ while req_id not in self._results:
202
+ remaining = deadline - time.time()
203
+ if remaining <= 0:
204
+ raise RuntimeError(f"MCP call timed out waiting for response {req_id}")
205
+ self._cv.wait(remaining)
206
+ return self._results.pop(req_id)
207
+
208
+
209
+ def detect_mcp_config(project_root: Path) -> tuple[str, str] | None:
210
+ """Check env vars then .mcp.json for MCP server URL and API key.
211
+
212
+ Returns ``(url, api_key)`` or ``None`` if not configured.
213
+ """
214
+ url = os.environ.get("DEVTORCH_MCP_URL", "").strip()
215
+ api_key = os.environ.get("DEVTORCH_API_KEY", "").strip()
216
+
217
+ if not url or not api_key:
218
+ mcp_path = project_root / ".mcp.json"
219
+ try:
220
+ data = json.loads(mcp_path.read_text(encoding="utf-8"))
221
+ env_cfg = data.get("mcpServers", {}).get("devtorch", {}).get("env", {})
222
+ url = url or env_cfg.get("DEVTORCH_MCP_URL", "").strip()
223
+ api_key = api_key or env_cfg.get("DEVTORCH_API_KEY", "").strip()
224
+ except (OSError, json.JSONDecodeError, KeyError, TypeError):
225
+ pass
226
+
227
+ if url and api_key:
228
+ return (url, api_key)
229
+ return None