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.
- devtorch_core/__init__.py +158 -0
- devtorch_core/aggphi_textual.py +275 -0
- devtorch_core/alerts/__init__.py +23 -0
- devtorch_core/alerts/base.py +46 -0
- devtorch_core/alerts/config.py +60 -0
- devtorch_core/alerts/dispatcher.py +110 -0
- devtorch_core/alerts/jira.py +96 -0
- devtorch_core/alerts/linear.py +72 -0
- devtorch_core/alerts/pagerduty.py +66 -0
- devtorch_core/alerts/slack.py +81 -0
- devtorch_core/alerts/teams.py +70 -0
- devtorch_core/audit/__init__.py +43 -0
- devtorch_core/audit/exporter.py +297 -0
- devtorch_core/audit/privacy.py +101 -0
- devtorch_core/audit/scrubber.py +149 -0
- devtorch_core/audit/service.py +67 -0
- devtorch_core/audit/signing.py +127 -0
- devtorch_core/broadcast/__init__.py +4 -0
- devtorch_core/broadcast/broadcaster.py +100 -0
- devtorch_core/broadcast/watcher.py +71 -0
- devtorch_core/capability.py +639 -0
- devtorch_core/cloud/__init__.py +1 -0
- devtorch_core/cloud/client_config.py +472 -0
- devtorch_core/cloud/client_configs/.claude-opencode-fallback.json +8 -0
- devtorch_core/cloud/client_configs/.claude-stdio.json +13 -0
- devtorch_core/cloud/client_configs/.cursor-mcp.json +13 -0
- devtorch_core/cloud/client_configs/.opencode-bridge.json +13 -0
- devtorch_core/cloud/client_configs/.opencode.json +15 -0
- devtorch_core/cloud/client_configs/.vscode-mcp.json +13 -0
- devtorch_core/cloud/devtorch-mcp-bridge.js +357 -0
- devtorch_core/cloud/mcp_client.py +229 -0
- devtorch_core/cloud/setup.py +144 -0
- devtorch_core/cloud/sync.py +143 -0
- devtorch_core/cloud/sync_bundle.py +603 -0
- devtorch_core/cloud/sync_conflicts.py +159 -0
- devtorch_core/cloud/sync_state.py +159 -0
- devtorch_core/cloud/team_sync.py +283 -0
- devtorch_core/codex/__init__.py +9 -0
- devtorch_core/codex/__main__.py +97 -0
- devtorch_core/codex/capture.py +208 -0
- devtorch_core/codex/proxy.py +412 -0
- devtorch_core/concept_catalog.py +209 -0
- devtorch_core/consolidation/__init__.py +3 -0
- devtorch_core/consolidation/synthesizer.py +87 -0
- devtorch_core/consolidation/workflow.py +175 -0
- devtorch_core/daemon/__init__.py +27 -0
- devtorch_core/daemon/supervisor.py +293 -0
- devtorch_core/daemon/watcher.py +244 -0
- devtorch_core/dashboard_api.py +2012 -0
- devtorch_core/deltaf.py +97 -0
- devtorch_core/disclosure.py +50 -0
- devtorch_core/divergence/__init__.py +3 -0
- devtorch_core/divergence/detector.py +166 -0
- devtorch_core/gateway/__init__.py +32 -0
- devtorch_core/gateway/key_manager.py +124 -0
- devtorch_core/gateway/metrics_webhook.py +252 -0
- devtorch_core/gateway/policy.py +262 -0
- devtorch_core/gateway/server.py +727 -0
- devtorch_core/gateway/sso.py +233 -0
- devtorch_core/gcc.py +1246 -0
- devtorch_core/github/__init__.py +35 -0
- devtorch_core/github/app.py +240 -0
- devtorch_core/github/comment_builder.py +113 -0
- devtorch_core/github/pat.py +76 -0
- devtorch_core/github/pr_parser.py +82 -0
- devtorch_core/github/pr_reporter.py +555 -0
- devtorch_core/gitlab/__init__.py +177 -0
- devtorch_core/hitl/__init__.py +4 -0
- devtorch_core/hitl/channels.py +129 -0
- devtorch_core/hitl/orchestrator.py +95 -0
- devtorch_core/hooks/__init__.py +17 -0
- devtorch_core/hooks/claude_code.py +228 -0
- devtorch_core/hooks/git_capture.py +341 -0
- devtorch_core/hooks/git_commit.py +182 -0
- devtorch_core/hooks/installer.py +733 -0
- devtorch_core/hooks/pre_commit.py +157 -0
- devtorch_core/hooks/runner.py +344 -0
- devtorch_core/identity/__init__.py +4 -0
- devtorch_core/identity/agent.py +86 -0
- devtorch_core/identity/providers.py +85 -0
- devtorch_core/invariants.py +182 -0
- devtorch_core/mcp/__init__.py +10 -0
- devtorch_core/mcp/auth.py +177 -0
- devtorch_core/mcp/server.py +1049 -0
- devtorch_core/metrics/__init__.py +35 -0
- devtorch_core/metrics/aggregate.py +215 -0
- devtorch_core/metrics/calibrate.py +198 -0
- devtorch_core/metrics/calibration.py +125 -0
- devtorch_core/metrics/credibility.py +288 -0
- devtorch_core/metrics/delivery_time.py +70 -0
- devtorch_core/metrics/dhs.py +126 -0
- devtorch_core/metrics/mcs.py +96 -0
- devtorch_core/metrics/roi.py +88 -0
- devtorch_core/metrics/session_writer.py +81 -0
- devtorch_core/metrics/shadow_ai.py +117 -0
- devtorch_core/metrics/sprint_writer.py +243 -0
- devtorch_core/observability/__init__.py +78 -0
- devtorch_core/observability/datadog.py +157 -0
- devtorch_core/observability/formatter.py +119 -0
- devtorch_core/observability/report.py +264 -0
- devtorch_core/observability/servicenow.py +147 -0
- devtorch_core/observability/splunk.py +218 -0
- devtorch_core/observability/webhook.py +227 -0
- devtorch_core/parser/__init__.py +30 -0
- devtorch_core/parser/blocks.py +216 -0
- devtorch_core/parser/inference.py +159 -0
- devtorch_core/parser/thinking.py +112 -0
- devtorch_core/projects.py +169 -0
- devtorch_core/prompt_artifact.py +76 -0
- devtorch_core/proxy/__init__.py +9 -0
- devtorch_core/proxy/routes/__init__.py +1 -0
- devtorch_core/proxy/routes/anthropic.py +264 -0
- devtorch_core/proxy/routes/azure_openai.py +336 -0
- devtorch_core/proxy/routes/gemini.py +331 -0
- devtorch_core/proxy/routes/groq.py +284 -0
- devtorch_core/proxy/routes/ollama.py +279 -0
- devtorch_core/proxy/routes/openai.py +287 -0
- devtorch_core/proxy/server.py +356 -0
- devtorch_core/query/__init__.py +15 -0
- devtorch_core/query/grep.py +181 -0
- devtorch_core/query/hybrid.py +86 -0
- devtorch_core/query/semantic.py +157 -0
- devtorch_core/rdp.py +105 -0
- devtorch_core/reasoning/__init__.py +4 -0
- devtorch_core/reasoning/entry.py +31 -0
- devtorch_core/reasoning/store.py +122 -0
- devtorch_core/reasoning_plus/__init__.py +70 -0
- devtorch_core/reasoning_plus/augmenter.py +326 -0
- devtorch_core/reasoning_plus/capture.py +51 -0
- devtorch_core/reasoning_plus/config.py +256 -0
- devtorch_core/reasoning_plus/context.py +262 -0
- devtorch_core/reasoning_plus/learning/__init__.py +72 -0
- devtorch_core/reasoning_plus/learning/analytics.py +141 -0
- devtorch_core/reasoning_plus/learning/api.py +313 -0
- devtorch_core/reasoning_plus/learning/chain.py +285 -0
- devtorch_core/reasoning_plus/learning/composer.py +74 -0
- devtorch_core/reasoning_plus/learning/cross_project.py +234 -0
- devtorch_core/reasoning_plus/learning/embeddings.py +209 -0
- devtorch_core/reasoning_plus/learning/extractor.py +207 -0
- devtorch_core/reasoning_plus/learning/models.py +116 -0
- devtorch_core/reasoning_plus/learning/provenance.py +126 -0
- devtorch_core/reasoning_plus/learning/recorder.py +81 -0
- devtorch_core/reasoning_plus/learning/relevance.py +122 -0
- devtorch_core/reasoning_plus/learning/state.py +86 -0
- devtorch_core/reasoning_plus/learning/store.py +160 -0
- devtorch_core/reasoning_plus/learning/theta_learning_bridge.py +94 -0
- devtorch_core/reasoning_plus/prompt.py +90 -0
- devtorch_core/rep.py +134 -0
- devtorch_core/rep_network/__init__.py +25 -0
- devtorch_core/rep_network/merge.py +70 -0
- devtorch_core/rep_network/node.py +137 -0
- devtorch_core/rep_network/server.py +140 -0
- devtorch_core/rep_network/sync.py +207 -0
- devtorch_core/sensitivity.py +182 -0
- devtorch_core/serve.py +258 -0
- devtorch_core/session/__init__.py +39 -0
- devtorch_core/session/disagreement.py +188 -0
- devtorch_core/session/models.py +114 -0
- devtorch_core/session/orchestrator.py +182 -0
- devtorch_core/session/planner.py +169 -0
- devtorch_core/session/simulator.py +132 -0
- devtorch_core/signing.py +290 -0
- devtorch_core/sis.py +197 -0
- devtorch_core/storage.py +308 -0
- devtorch_core/templates/__init__.py +6 -0
- devtorch_core/templates/engine.py +122 -0
- devtorch_core/templates/go.py +18 -0
- devtorch_core/templates/infra.py +19 -0
- devtorch_core/templates/library/__init__.py +18 -0
- devtorch_core/templates/library/api_design.md +27 -0
- devtorch_core/templates/library/bug_fix.md +27 -0
- devtorch_core/templates/library/decision_record.md +27 -0
- devtorch_core/templates/library/engine.py +228 -0
- devtorch_core/templates/library/security_review.md +30 -0
- devtorch_core/templates/python.py +19 -0
- devtorch_core/templates/react.py +18 -0
- devtorch_core/templates/typescript.py +18 -0
- devtorch_core/theta.py +221 -0
- devtorch_core/theta_synthesis.py +268 -0
- devtorch_core/topics.py +320 -0
- devtorch_core/variance.py +219 -0
- devtorch_core/wrapper/__init__.py +52 -0
- devtorch_core/wrapper/anthropic.py +487 -0
- devtorch_core/wrapper/base.py +562 -0
- devtorch_core/wrapper/bedrock.py +342 -0
- devtorch_core/wrapper/gemini.py +422 -0
- devtorch_core/wrapper/ollama.py +527 -0
- devtorch_core/wrapper/openai.py +461 -0
- devtorch_core-3.0.1.dist-info/METADATA +867 -0
- devtorch_core-3.0.1.dist-info/RECORD +193 -0
- devtorch_core-3.0.1.dist-info/WHEEL +5 -0
- devtorch_core-3.0.1.dist-info/entry_points.txt +2 -0
- 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
|