thinkstack-core 4.0.0__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.
- thinkstack_core/__init__.py +158 -0
- thinkstack_core/aggphi_textual.py +275 -0
- thinkstack_core/alerts/__init__.py +23 -0
- thinkstack_core/alerts/base.py +46 -0
- thinkstack_core/alerts/config.py +60 -0
- thinkstack_core/alerts/dispatcher.py +110 -0
- thinkstack_core/alerts/jira.py +96 -0
- thinkstack_core/alerts/linear.py +72 -0
- thinkstack_core/alerts/pagerduty.py +66 -0
- thinkstack_core/alerts/slack.py +81 -0
- thinkstack_core/alerts/teams.py +70 -0
- thinkstack_core/audit/__init__.py +43 -0
- thinkstack_core/audit/exporter.py +297 -0
- thinkstack_core/audit/privacy.py +101 -0
- thinkstack_core/audit/scrubber.py +149 -0
- thinkstack_core/audit/service.py +67 -0
- thinkstack_core/audit/signing.py +127 -0
- thinkstack_core/broadcast/__init__.py +4 -0
- thinkstack_core/broadcast/broadcaster.py +100 -0
- thinkstack_core/broadcast/watcher.py +71 -0
- thinkstack_core/capability.py +639 -0
- thinkstack_core/cloud/__init__.py +1 -0
- thinkstack_core/cloud/client_config.py +472 -0
- thinkstack_core/cloud/client_configs/.claude-opencode-fallback.json +8 -0
- thinkstack_core/cloud/client_configs/.claude-stdio.json +13 -0
- thinkstack_core/cloud/client_configs/.cursor-mcp.json +13 -0
- thinkstack_core/cloud/client_configs/.opencode-bridge.json +13 -0
- thinkstack_core/cloud/client_configs/.opencode.json +15 -0
- thinkstack_core/cloud/client_configs/.vscode-mcp.json +13 -0
- thinkstack_core/cloud/mcp_client.py +229 -0
- thinkstack_core/cloud/setup.py +144 -0
- thinkstack_core/cloud/sync.py +143 -0
- thinkstack_core/cloud/sync_bundle.py +639 -0
- thinkstack_core/cloud/sync_conflicts.py +183 -0
- thinkstack_core/cloud/sync_state.py +159 -0
- thinkstack_core/cloud/team_sync.py +337 -0
- thinkstack_core/cloud/thinkstack-mcp-bridge.js +357 -0
- thinkstack_core/codex/__init__.py +9 -0
- thinkstack_core/codex/__main__.py +97 -0
- thinkstack_core/codex/capture.py +208 -0
- thinkstack_core/codex/proxy.py +412 -0
- thinkstack_core/compat.py +103 -0
- thinkstack_core/concept_catalog.py +209 -0
- thinkstack_core/consolidation/__init__.py +3 -0
- thinkstack_core/consolidation/synthesizer.py +87 -0
- thinkstack_core/consolidation/workflow.py +175 -0
- thinkstack_core/daemon/__init__.py +27 -0
- thinkstack_core/daemon/supervisor.py +293 -0
- thinkstack_core/daemon/watcher.py +244 -0
- thinkstack_core/dashboard_api.py +2012 -0
- thinkstack_core/deltaf.py +97 -0
- thinkstack_core/disclosure.py +50 -0
- thinkstack_core/divergence/__init__.py +3 -0
- thinkstack_core/divergence/detector.py +166 -0
- thinkstack_core/gateway/__init__.py +32 -0
- thinkstack_core/gateway/key_manager.py +124 -0
- thinkstack_core/gateway/metrics_webhook.py +252 -0
- thinkstack_core/gateway/policy.py +262 -0
- thinkstack_core/gateway/server.py +727 -0
- thinkstack_core/gateway/sso.py +233 -0
- thinkstack_core/gcc.py +1246 -0
- thinkstack_core/github/__init__.py +35 -0
- thinkstack_core/github/app.py +240 -0
- thinkstack_core/github/comment_builder.py +113 -0
- thinkstack_core/github/pat.py +76 -0
- thinkstack_core/github/pr_parser.py +82 -0
- thinkstack_core/github/pr_reporter.py +555 -0
- thinkstack_core/gitlab/__init__.py +177 -0
- thinkstack_core/hitl/__init__.py +4 -0
- thinkstack_core/hitl/channels.py +129 -0
- thinkstack_core/hitl/orchestrator.py +95 -0
- thinkstack_core/hooks/__init__.py +17 -0
- thinkstack_core/hooks/claude_code.py +228 -0
- thinkstack_core/hooks/git_capture.py +341 -0
- thinkstack_core/hooks/git_commit.py +182 -0
- thinkstack_core/hooks/installer.py +850 -0
- thinkstack_core/hooks/pre_commit.py +157 -0
- thinkstack_core/hooks/runner.py +386 -0
- thinkstack_core/identity/__init__.py +4 -0
- thinkstack_core/identity/agent.py +86 -0
- thinkstack_core/identity/providers.py +85 -0
- thinkstack_core/invariants.py +182 -0
- thinkstack_core/mcp/__init__.py +10 -0
- thinkstack_core/mcp/auth.py +177 -0
- thinkstack_core/mcp/server.py +1215 -0
- thinkstack_core/metrics/__init__.py +35 -0
- thinkstack_core/metrics/aggregate.py +215 -0
- thinkstack_core/metrics/calibrate.py +198 -0
- thinkstack_core/metrics/calibration.py +125 -0
- thinkstack_core/metrics/credibility.py +288 -0
- thinkstack_core/metrics/delivery_time.py +70 -0
- thinkstack_core/metrics/dhs.py +126 -0
- thinkstack_core/metrics/mcs.py +96 -0
- thinkstack_core/metrics/roi.py +88 -0
- thinkstack_core/metrics/session_writer.py +81 -0
- thinkstack_core/metrics/shadow_ai.py +117 -0
- thinkstack_core/metrics/sprint_writer.py +243 -0
- thinkstack_core/observability/__init__.py +78 -0
- thinkstack_core/observability/datadog.py +157 -0
- thinkstack_core/observability/formatter.py +119 -0
- thinkstack_core/observability/report.py +264 -0
- thinkstack_core/observability/servicenow.py +147 -0
- thinkstack_core/observability/splunk.py +218 -0
- thinkstack_core/observability/webhook.py +227 -0
- thinkstack_core/parser/__init__.py +30 -0
- thinkstack_core/parser/blocks.py +216 -0
- thinkstack_core/parser/inference.py +159 -0
- thinkstack_core/parser/thinking.py +112 -0
- thinkstack_core/projects.py +169 -0
- thinkstack_core/prompt_artifact.py +76 -0
- thinkstack_core/proxy/__init__.py +9 -0
- thinkstack_core/proxy/routes/__init__.py +1 -0
- thinkstack_core/proxy/routes/anthropic.py +264 -0
- thinkstack_core/proxy/routes/azure_openai.py +336 -0
- thinkstack_core/proxy/routes/gemini.py +331 -0
- thinkstack_core/proxy/routes/groq.py +284 -0
- thinkstack_core/proxy/routes/ollama.py +279 -0
- thinkstack_core/proxy/routes/openai.py +287 -0
- thinkstack_core/proxy/server.py +356 -0
- thinkstack_core/query/__init__.py +15 -0
- thinkstack_core/query/grep.py +181 -0
- thinkstack_core/query/hybrid.py +86 -0
- thinkstack_core/query/semantic.py +157 -0
- thinkstack_core/rdp.py +105 -0
- thinkstack_core/reasoning/__init__.py +4 -0
- thinkstack_core/reasoning/entry.py +31 -0
- thinkstack_core/reasoning/store.py +122 -0
- thinkstack_core/reasoning_plus/__init__.py +70 -0
- thinkstack_core/reasoning_plus/augmenter.py +337 -0
- thinkstack_core/reasoning_plus/capture.py +51 -0
- thinkstack_core/reasoning_plus/config.py +313 -0
- thinkstack_core/reasoning_plus/context.py +262 -0
- thinkstack_core/reasoning_plus/learning/__init__.py +125 -0
- thinkstack_core/reasoning_plus/learning/analytics.py +141 -0
- thinkstack_core/reasoning_plus/learning/api.py +784 -0
- thinkstack_core/reasoning_plus/learning/chain.py +285 -0
- thinkstack_core/reasoning_plus/learning/composer.py +141 -0
- thinkstack_core/reasoning_plus/learning/conflicts.py +184 -0
- thinkstack_core/reasoning_plus/learning/context_collector.py +194 -0
- thinkstack_core/reasoning_plus/learning/cross_project.py +234 -0
- thinkstack_core/reasoning_plus/learning/deny_list.py +108 -0
- thinkstack_core/reasoning_plus/learning/embeddings.py +209 -0
- thinkstack_core/reasoning_plus/learning/evolution.py +119 -0
- thinkstack_core/reasoning_plus/learning/extractor.py +271 -0
- thinkstack_core/reasoning_plus/learning/filter_five_layer.py +95 -0
- thinkstack_core/reasoning_plus/learning/models.py +149 -0
- thinkstack_core/reasoning_plus/learning/org_store.py +156 -0
- thinkstack_core/reasoning_plus/learning/pii.py +142 -0
- thinkstack_core/reasoning_plus/learning/promotion.py +58 -0
- thinkstack_core/reasoning_plus/learning/provenance.py +126 -0
- thinkstack_core/reasoning_plus/learning/recorder.py +81 -0
- thinkstack_core/reasoning_plus/learning/relevance.py +122 -0
- thinkstack_core/reasoning_plus/learning/state.py +86 -0
- thinkstack_core/reasoning_plus/learning/store.py +178 -0
- thinkstack_core/reasoning_plus/learning/theta_learning_bridge.py +94 -0
- thinkstack_core/reasoning_plus/prompt.py +90 -0
- thinkstack_core/rep.py +134 -0
- thinkstack_core/rep_network/__init__.py +25 -0
- thinkstack_core/rep_network/merge.py +70 -0
- thinkstack_core/rep_network/node.py +137 -0
- thinkstack_core/rep_network/server.py +140 -0
- thinkstack_core/rep_network/sync.py +207 -0
- thinkstack_core/sensitivity.py +182 -0
- thinkstack_core/serve.py +258 -0
- thinkstack_core/session/__init__.py +39 -0
- thinkstack_core/session/disagreement.py +188 -0
- thinkstack_core/session/models.py +114 -0
- thinkstack_core/session/orchestrator.py +182 -0
- thinkstack_core/session/planner.py +169 -0
- thinkstack_core/session/simulator.py +132 -0
- thinkstack_core/signing.py +290 -0
- thinkstack_core/sis.py +197 -0
- thinkstack_core/skills/pr-reviewer/SKILL.md +204 -0
- thinkstack_core/skills/thinkstack-auto-sync/SKILL.md +175 -0
- thinkstack_core/skills/thinkstack-session-start/SKILL.md +136 -0
- thinkstack_core/storage.py +308 -0
- thinkstack_core/templates/__init__.py +6 -0
- thinkstack_core/templates/engine.py +122 -0
- thinkstack_core/templates/go.py +18 -0
- thinkstack_core/templates/infra.py +19 -0
- thinkstack_core/templates/library/__init__.py +18 -0
- thinkstack_core/templates/library/api_design.md +27 -0
- thinkstack_core/templates/library/bug_fix.md +27 -0
- thinkstack_core/templates/library/decision_record.md +27 -0
- thinkstack_core/templates/library/engine.py +228 -0
- thinkstack_core/templates/library/security_review.md +30 -0
- thinkstack_core/templates/python.py +19 -0
- thinkstack_core/templates/react.py +18 -0
- thinkstack_core/templates/typescript.py +18 -0
- thinkstack_core/theta.py +221 -0
- thinkstack_core/theta_synthesis.py +268 -0
- thinkstack_core/topics.py +320 -0
- thinkstack_core/variance.py +219 -0
- thinkstack_core/wrapper/__init__.py +52 -0
- thinkstack_core/wrapper/anthropic.py +487 -0
- thinkstack_core/wrapper/base.py +562 -0
- thinkstack_core/wrapper/bedrock.py +342 -0
- thinkstack_core/wrapper/gemini.py +422 -0
- thinkstack_core/wrapper/ollama.py +527 -0
- thinkstack_core/wrapper/openai.py +461 -0
- thinkstack_core-4.0.0.dist-info/METADATA +868 -0
- thinkstack_core-4.0.0.dist-info/RECORD +205 -0
- thinkstack_core-4.0.0.dist-info/WHEEL +5 -0
- thinkstack_core-4.0.0.dist-info/entry_points.txt +2 -0
- thinkstack_core-4.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""
|
|
2
|
+
thinkstack_core.cloud.mcp_client
|
|
3
|
+
==============================
|
|
4
|
+
Reusable client for calling remote ThinkStack 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("thinkstack.cloud.mcp_client")
|
|
23
|
+
|
|
24
|
+
_USER_AGENT = "thinkstack-mcp-client"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class McpClient:
|
|
28
|
+
"""Call remote ThinkStack MCP tools via JSON-RPC over SSE.
|
|
29
|
+
|
|
30
|
+
Usage::
|
|
31
|
+
|
|
32
|
+
client = McpClient(url, api_key)
|
|
33
|
+
result = client.call_tool("thinkstack_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-ThinkStack-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-ThinkStack-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("THINKSTACK_MCP_URL", "").strip()
|
|
215
|
+
api_key = os.environ.get("THINKSTACK_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("thinkstack", {}).get("env", {})
|
|
222
|
+
url = url or env_cfg.get("THINKSTACK_MCP_URL", "").strip()
|
|
223
|
+
api_key = api_key or env_cfg.get("THINKSTACK_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
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""One-command Cloudflare Worker deployment for ThinkStack MCP server.
|
|
2
|
+
|
|
3
|
+
This module wraps ``deploy/cloudflare-worker/setup-mcp-server.sh`` so it can be
|
|
4
|
+
called as ``thinkstack cloud setup``. It is intentionally thin: the shell script
|
|
5
|
+
still owns the npm/wrangler lifecycle; Python only validates CLI arguments and
|
|
6
|
+
forwards them.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Sequence
|
|
16
|
+
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
# Constants
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
_PACKAGE_DIR = Path(__file__).resolve().parent
|
|
22
|
+
_REPO_ROOT = _PACKAGE_DIR.parents[1]
|
|
23
|
+
SETUP_SCRIPT = _REPO_ROOT / "deploy" / "cloudflare-worker" / "setup-mcp-server.sh"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# Public API
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def run_cloud_setup(
|
|
32
|
+
*,
|
|
33
|
+
env_file: str | Path | None = None,
|
|
34
|
+
org: str | None = None,
|
|
35
|
+
repo: str | None = None,
|
|
36
|
+
ide: str | None = None,
|
|
37
|
+
openai_key: str | None = None,
|
|
38
|
+
non_interactive: bool = False,
|
|
39
|
+
skip_key: bool = False,
|
|
40
|
+
skip_client_config: bool = False,
|
|
41
|
+
dry_run: bool = False,
|
|
42
|
+
) -> dict[str, str | list[str]]:
|
|
43
|
+
"""Build and optionally run the Cloudflare Worker setup command.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
env_file: Path to an environment file to source before running the script.
|
|
47
|
+
org: ThinkStack org ID. Required in non-interactive mode.
|
|
48
|
+
repo: ThinkStack repo ID. Required in non-interactive mode.
|
|
49
|
+
ide: IDE to configure (opencode, claude, cursor, vscode, stdio).
|
|
50
|
+
openai_key: Optional OpenAI API key for the DRPL embedding backend.
|
|
51
|
+
non_interactive: Run without prompts (requires ``org`` and ``repo``).
|
|
52
|
+
skip_key: Skip creating a new API key.
|
|
53
|
+
skip_client_config: Skip installing MCP client config.
|
|
54
|
+
dry_run: If True, return the command without executing it.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
A dict with ``script`` and ``args`` (the constructed argument list).
|
|
58
|
+
"""
|
|
59
|
+
if not SETUP_SCRIPT.exists():
|
|
60
|
+
raise FileNotFoundError(
|
|
61
|
+
f"Setup script not found: {SETUP_SCRIPT}. "
|
|
62
|
+
"Is the ThinkStack repo checkout complete?"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
if non_interactive and (not org or not repo):
|
|
66
|
+
raise ValueError("--non-interactive requires --org and --repo")
|
|
67
|
+
|
|
68
|
+
if non_interactive and not skip_client_config and not ide:
|
|
69
|
+
raise ValueError("--non-interactive requires --ide unless --skip-client-config is set")
|
|
70
|
+
|
|
71
|
+
args: list[str] = [str(SETUP_SCRIPT)]
|
|
72
|
+
if env_file:
|
|
73
|
+
args.extend(["--env-file", str(env_file)])
|
|
74
|
+
if non_interactive:
|
|
75
|
+
args.append("--non-interactive")
|
|
76
|
+
if org:
|
|
77
|
+
args.extend(["--org", org])
|
|
78
|
+
if repo:
|
|
79
|
+
args.extend(["--repo", repo])
|
|
80
|
+
if ide:
|
|
81
|
+
args.extend(["--ide", ide])
|
|
82
|
+
if openai_key:
|
|
83
|
+
args.extend(["--openai-key", openai_key])
|
|
84
|
+
if skip_key:
|
|
85
|
+
args.append("--skip-key")
|
|
86
|
+
if skip_client_config:
|
|
87
|
+
args.append("--skip-client-config")
|
|
88
|
+
|
|
89
|
+
if dry_run:
|
|
90
|
+
return {"script": str(SETUP_SCRIPT), "args": args}
|
|
91
|
+
|
|
92
|
+
result = subprocess.run(args, capture_output=False, text=True, check=False)
|
|
93
|
+
return {
|
|
94
|
+
"script": str(SETUP_SCRIPT),
|
|
95
|
+
"args": args,
|
|
96
|
+
"returncode": str(result.returncode),
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
101
|
+
"""CLI entry point used by ``thinkstack cloud setup``."""
|
|
102
|
+
import argparse
|
|
103
|
+
|
|
104
|
+
parser = argparse.ArgumentParser(
|
|
105
|
+
description="Deploy a self-hosted ThinkStack MCP server on Cloudflare Workers.",
|
|
106
|
+
)
|
|
107
|
+
parser.add_argument("--env-file", help="Path to environment file to source")
|
|
108
|
+
parser.add_argument("--org", help="ThinkStack org ID")
|
|
109
|
+
parser.add_argument("--repo", help="ThinkStack repo ID")
|
|
110
|
+
parser.add_argument("--ide", choices=["opencode", "claude", "cursor", "vscode", "stdio"],
|
|
111
|
+
help="IDE to configure locally")
|
|
112
|
+
parser.add_argument("--openai-key", help="OpenAI API key for DRPL embedding backend")
|
|
113
|
+
parser.add_argument("--non-interactive", action="store_true",
|
|
114
|
+
help="Run without prompts (requires --org and --repo)")
|
|
115
|
+
parser.add_argument("--skip-key", action="store_true", help="Skip creating API key")
|
|
116
|
+
parser.add_argument("--skip-client-config", action="store_true", help="Skip IDE config")
|
|
117
|
+
parser.add_argument("--dry-run", action="store_true", help="Print command instead of running")
|
|
118
|
+
args = parser.parse_args(argv)
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
result = run_cloud_setup(
|
|
122
|
+
env_file=args.env_file,
|
|
123
|
+
org=args.org,
|
|
124
|
+
repo=args.repo,
|
|
125
|
+
ide=args.ide,
|
|
126
|
+
openai_key=args.openai_key,
|
|
127
|
+
non_interactive=args.non_interactive,
|
|
128
|
+
skip_key=args.skip_key,
|
|
129
|
+
skip_client_config=args.skip_client_config,
|
|
130
|
+
dry_run=args.dry_run,
|
|
131
|
+
)
|
|
132
|
+
except (FileNotFoundError, ValueError) as exc:
|
|
133
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
134
|
+
return 1
|
|
135
|
+
|
|
136
|
+
if args.dry_run:
|
|
137
|
+
print(" ".join(result["args"]))
|
|
138
|
+
return 0
|
|
139
|
+
|
|
140
|
+
return int(result.get("returncode", "0"))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
if __name__ == "__main__":
|
|
144
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""One-way local -> cloud reasoning sync.
|
|
2
|
+
|
|
3
|
+
Pushes local .GCC commits to the ThinkStack cloud MCP worker so that
|
|
4
|
+
cloud-connected agents' `thinkstack_context` sees reasoning recorded by
|
|
5
|
+
local-only agents (hooks / CLI). Uses the worker's direct-POST fallback:
|
|
6
|
+
POST /messages without an SSE session returns the JSON-RPC result in the
|
|
7
|
+
response body.
|
|
8
|
+
|
|
9
|
+
Transport hardening: if the direct-POST path fails, we open an SSE
|
|
10
|
+
handshake once, capture the live endpoint URL, and retry through that
|
|
11
|
+
session-bound endpoint. The SSE-derived URL is cached for all subsequent
|
|
12
|
+
commits in the same sync run.
|
|
13
|
+
|
|
14
|
+
Cursor: .GCC/cloud_sync_state.json {"last_pushed_index": N}. Commit ids are
|
|
15
|
+
sequential hex (COMMIT_INDEX), so the cursor is a simple integer.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import urllib.error
|
|
22
|
+
import urllib.request
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
_STATE_FILE = "cloud_sync_state.json"
|
|
26
|
+
_USER_AGENT = "thinkstack-cloud-sync"
|
|
27
|
+
|
|
28
|
+
_SSE_ENDPOINT_CACHE: str | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _sse_handshake(sse_url: str, api_key: str, timeout: float) -> str:
|
|
32
|
+
"""Open an SSE connection, capture the endpoint URL, and return it."""
|
|
33
|
+
req = urllib.request.Request(
|
|
34
|
+
sse_url,
|
|
35
|
+
headers={
|
|
36
|
+
"Accept": "text/event-stream",
|
|
37
|
+
"X-ThinkStack-Key": api_key,
|
|
38
|
+
"User-Agent": _USER_AGENT,
|
|
39
|
+
},
|
|
40
|
+
)
|
|
41
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
42
|
+
# Read line-by-line so the chunked HTTP reader does not block waiting
|
|
43
|
+
# for the next chunk after the endpoint event (the server keeps the SSE
|
|
44
|
+
# connection open for subsequent messages).
|
|
45
|
+
event_type = None
|
|
46
|
+
data = None
|
|
47
|
+
while True:
|
|
48
|
+
line = resp.readline()
|
|
49
|
+
if not line:
|
|
50
|
+
break
|
|
51
|
+
line = line.decode("utf-8", errors="replace")
|
|
52
|
+
if line.startswith("event:"):
|
|
53
|
+
event_type = line[6:].strip()
|
|
54
|
+
elif line.startswith("data:"):
|
|
55
|
+
data = line[5:].strip()
|
|
56
|
+
elif line.strip() == "":
|
|
57
|
+
if event_type == "endpoint" and data:
|
|
58
|
+
return data
|
|
59
|
+
event_type = None
|
|
60
|
+
data = None
|
|
61
|
+
raise RuntimeError("SSE endpoint event not received")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def sync_commits(
|
|
65
|
+
gcc_dir: Path,
|
|
66
|
+
messages_url: str,
|
|
67
|
+
api_key: str,
|
|
68
|
+
timeout: float = 30.0,
|
|
69
|
+
from_now: bool = False,
|
|
70
|
+
) -> dict:
|
|
71
|
+
gcc_dir = Path(gcc_dir)
|
|
72
|
+
if not gcc_dir.is_dir():
|
|
73
|
+
raise ValueError(f"{gcc_dir} is not an initialized .GCC repository")
|
|
74
|
+
try:
|
|
75
|
+
top = int((gcc_dir / "COMMIT_INDEX").read_text(encoding="utf-8").strip() or "0")
|
|
76
|
+
except (OSError, ValueError):
|
|
77
|
+
top = 0
|
|
78
|
+
|
|
79
|
+
state_path = gcc_dir / _STATE_FILE
|
|
80
|
+
try:
|
|
81
|
+
cursor = int(json.loads(state_path.read_text(encoding="utf-8"))["last_pushed_index"])
|
|
82
|
+
except (OSError, ValueError, KeyError, json.JSONDecodeError):
|
|
83
|
+
cursor = 0
|
|
84
|
+
|
|
85
|
+
if cursor > top:
|
|
86
|
+
cursor = 0
|
|
87
|
+
|
|
88
|
+
if from_now:
|
|
89
|
+
state_path.write_text(
|
|
90
|
+
json.dumps({"last_pushed_index": top}) + "\n", encoding="utf-8"
|
|
91
|
+
)
|
|
92
|
+
return {"pushed": 0, "skipped": top, "cursor": top}
|
|
93
|
+
|
|
94
|
+
global _SSE_ENDPOINT_CACHE
|
|
95
|
+
pushed = 0
|
|
96
|
+
for index in range(cursor + 1, top + 1):
|
|
97
|
+
commit_id = format(index, "08x")
|
|
98
|
+
commit_path = gcc_dir / "commits" / f"{commit_id}.json"
|
|
99
|
+
try:
|
|
100
|
+
obj = json.loads(commit_path.read_text(encoding="utf-8"))
|
|
101
|
+
except (OSError, json.JSONDecodeError):
|
|
102
|
+
continue
|
|
103
|
+
message = (
|
|
104
|
+
f"[synced from local {commit_id} @ {obj.get('timestamp', 'unknown')}] "
|
|
105
|
+
f"{obj.get('message', '')}"
|
|
106
|
+
)
|
|
107
|
+
try:
|
|
108
|
+
_post_commit(messages_url, api_key, index, message, timeout)
|
|
109
|
+
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError):
|
|
110
|
+
if _SSE_ENDPOINT_CACHE is None:
|
|
111
|
+
sse_url = messages_url.rsplit("/", 1)[0] + "/sse"
|
|
112
|
+
_SSE_ENDPOINT_CACHE = _sse_handshake(sse_url, api_key, timeout)
|
|
113
|
+
_post_commit(_SSE_ENDPOINT_CACHE, api_key, index, message, timeout)
|
|
114
|
+
pushed += 1
|
|
115
|
+
state_path.write_text(
|
|
116
|
+
json.dumps({"last_pushed_index": index}) + "\n", encoding="utf-8"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
return {"pushed": pushed, "skipped": cursor, "cursor": max(cursor, top)}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _post_commit(
|
|
123
|
+
messages_url: str, api_key: str, rpc_id: int, message: str, timeout: float
|
|
124
|
+
) -> None:
|
|
125
|
+
payload = {
|
|
126
|
+
"jsonrpc": "2.0",
|
|
127
|
+
"id": rpc_id,
|
|
128
|
+
"method": "tools/call",
|
|
129
|
+
"params": {"name": "thinkstack_commit", "arguments": {"message": message}},
|
|
130
|
+
}
|
|
131
|
+
req = urllib.request.Request(
|
|
132
|
+
messages_url,
|
|
133
|
+
data=json.dumps(payload).encode(),
|
|
134
|
+
headers={
|
|
135
|
+
"Content-Type": "application/json",
|
|
136
|
+
"X-ThinkStack-Key": api_key,
|
|
137
|
+
"User-Agent": _USER_AGENT,
|
|
138
|
+
},
|
|
139
|
+
)
|
|
140
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
141
|
+
body = json.loads(resp.read().decode())
|
|
142
|
+
if body.get("error") or body.get("result", {}).get("isError"):
|
|
143
|
+
raise RuntimeError(f"cloud commit failed: {json.dumps(body)[:200]}")
|