forgecommandline 0.8.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.
- forgecli/__init__.py +2 -0
- forgecli/ads.py +322 -0
- forgecli/agent.py +1093 -0
- forgecli/art.py +574 -0
- forgecli/attach.py +246 -0
- forgecli/brief.py +215 -0
- forgecli/browsercheck.py +99 -0
- forgecli/checkpoints.py +81 -0
- forgecli/city.py +833 -0
- forgecli/cli.py +1668 -0
- forgecli/config.py +194 -0
- forgecli/courier.py +177 -0
- forgecli/courier_data.py +164 -0
- forgecli/courier_hd.py +431 -0
- forgecli/courier_hd_data.bin +0 -0
- forgecli/courier_hd_data.py +19 -0
- forgecli/ghlogin.py +76 -0
- forgecli/github.py +54 -0
- forgecli/guards.py +403 -0
- forgecli/hooks.py +50 -0
- forgecli/httpapi.py +434 -0
- forgecli/images.py +110 -0
- forgecli/keys.py +152 -0
- forgecli/keyvault.py +320 -0
- forgecli/mcp.py +674 -0
- forgecli/memory.py +42 -0
- forgecli/openapi.py +331 -0
- forgecli/parallel.py +308 -0
- forgecli/prices.py +72 -0
- forgecli/procs.py +144 -0
- forgecli/providers.py +758 -0
- forgecli/quality.py +192 -0
- forgecli/repomap.py +78 -0
- forgecli/roster.py +227 -0
- forgecli/safety.py +93 -0
- forgecli/sandbox.py +83 -0
- forgecli/sessions.py +97 -0
- forgecli/skills.py +446 -0
- forgecli/tools.py +1184 -0
- forgecli/ui.py +860 -0
- forgecli/worktrees.py +79 -0
- forgecommandline-0.8.0.data/data/skills/commit/SKILL.md +14 -0
- forgecommandline-0.8.0.data/data/skills/debug/SKILL.md +17 -0
- forgecommandline-0.8.0.data/data/skills/explain/SKILL.md +14 -0
- forgecommandline-0.8.0.data/data/skills/init/SKILL.md +19 -0
- forgecommandline-0.8.0.data/data/skills/library/LICENSES/agent-skills-MIT.txt +21 -0
- forgecommandline-0.8.0.data/data/skills/library/LICENSES/wondelai-MIT.txt +23 -0
- forgecommandline-0.8.0.data/data/skills/library/README.md +9 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/accessibility-checklist.md +160 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/definition-of-done.md +67 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/observability-checklist.md +92 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/orchestration-patterns.md +370 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/performance-checklist.md +238 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/security-checklist.md +248 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/testing-patterns.md +235 -0
- forgecommandline-0.8.0.data/data/skills/library/api-and-interface-design/SKILL.md +367 -0
- forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/SKILL.md +279 -0
- forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/references/artifact-templates.md +157 -0
- forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/references/methods.md +87 -0
- forgecommandline-0.8.0.data/data/skills/library/awwwards-tier-web-application/SKILL.md +877 -0
- forgecommandline-0.8.0.data/data/skills/library/browser-testing-with-devtools/SKILL.md +317 -0
- forgecommandline-0.8.0.data/data/skills/library/ci-cd-and-automation/SKILL.md +390 -0
- forgecommandline-0.8.0.data/data/skills/library/code-review-and-quality/SKILL.md +398 -0
- forgecommandline-0.8.0.data/data/skills/library/code-simplification/SKILL.md +331 -0
- forgecommandline-0.8.0.data/data/skills/library/constraint-driven-development/SKILL.md +311 -0
- forgecommandline-0.8.0.data/data/skills/library/constraint-driven-development/references/floor-guard.md +182 -0
- forgecommandline-0.8.0.data/data/skills/library/context-engineering/SKILL.md +353 -0
- forgecommandline-0.8.0.data/data/skills/library/debugging-and-error-recovery/SKILL.md +300 -0
- forgecommandline-0.8.0.data/data/skills/library/deprecation-and-migration/SKILL.md +247 -0
- forgecommandline-0.8.0.data/data/skills/library/documentation-and-adrs/SKILL.md +297 -0
- forgecommandline-0.8.0.data/data/skills/library/doubt-driven-development/SKILL.md +243 -0
- forgecommandline-0.8.0.data/data/skills/library/frontend-ui-engineering/SKILL.md +340 -0
- forgecommandline-0.8.0.data/data/skills/library/git-workflow-and-versioning/SKILL.md +355 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/SKILL.md +193 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/examples.md +238 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/frameworks.md +99 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/refinement-criteria.md +113 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/scripts/idea-refine.sh +15 -0
- forgecommandline-0.8.0.data/data/skills/library/improve-code-quality/SKILL.md +275 -0
- forgecommandline-0.8.0.data/data/skills/library/improve-code-quality/references/artifact-templates.md +101 -0
- forgecommandline-0.8.0.data/data/skills/library/incremental-implementation/SKILL.md +249 -0
- forgecommandline-0.8.0.data/data/skills/library/interview-me/SKILL.md +235 -0
- forgecommandline-0.8.0.data/data/skills/library/observability-and-instrumentation/SKILL.md +238 -0
- forgecommandline-0.8.0.data/data/skills/library/performance-optimization/SKILL.md +267 -0
- forgecommandline-0.8.0.data/data/skills/library/performance-optimization/references/optimization-patterns.md +250 -0
- forgecommandline-0.8.0.data/data/skills/library/planning-and-task-breakdown/SKILL.md +257 -0
- forgecommandline-0.8.0.data/data/skills/library/remove-technical-debt/SKILL.md +203 -0
- forgecommandline-0.8.0.data/data/skills/library/remove-technical-debt/references/artifact-templates.md +79 -0
- forgecommandline-0.8.0.data/data/skills/library/security-and-hardening/SKILL.md +216 -0
- forgecommandline-0.8.0.data/data/skills/library/security-and-hardening/references/hardening-patterns.md +324 -0
- forgecommandline-0.8.0.data/data/skills/library/shipping-and-launch/SKILL.md +336 -0
- forgecommandline-0.8.0.data/data/skills/library/source-driven-development/SKILL.md +216 -0
- forgecommandline-0.8.0.data/data/skills/library/spec-driven-development/SKILL.md +259 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/SKILL.md +207 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/building-blocks.md +272 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/common-designs.md +257 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/database-scaling.md +295 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/estimation-numbers.md +250 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/four-step-process.md +286 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/reliability-operations.md +373 -0
- forgecommandline-0.8.0.data/data/skills/library/test-driven-development/SKILL.md +398 -0
- forgecommandline-0.8.0.data/data/skills/library/using-agent-skills/SKILL.md +192 -0
- forgecommandline-0.8.0.data/data/skills/review/SKILL.md +16 -0
- forgecommandline-0.8.0.data/data/skills/run/SKILL.md +17 -0
- forgecommandline-0.8.0.data/data/skills/security-review/SKILL.md +20 -0
- forgecommandline-0.8.0.data/data/skills/simplify/SKILL.md +18 -0
- forgecommandline-0.8.0.data/data/skills/test/SKILL.md +16 -0
- forgecommandline-0.8.0.data/data/skills/web-build/SKILL.md +25 -0
- forgecommandline-0.8.0.dist-info/METADATA +357 -0
- forgecommandline-0.8.0.dist-info/RECORD +113 -0
- forgecommandline-0.8.0.dist-info/WHEEL +5 -0
- forgecommandline-0.8.0.dist-info/entry_points.txt +2 -0
- forgecommandline-0.8.0.dist-info/top_level.txt +1 -0
forgecli/mcp.py
ADDED
|
@@ -0,0 +1,674 @@
|
|
|
1
|
+
"""Lazy MCP stdio and streamable-HTTP clients."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import atexit
|
|
5
|
+
import copy
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import pathlib
|
|
10
|
+
import queue
|
|
11
|
+
import re
|
|
12
|
+
import subprocess
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
import urllib.parse
|
|
16
|
+
import urllib.error
|
|
17
|
+
import urllib.request
|
|
18
|
+
|
|
19
|
+
from . import skills
|
|
20
|
+
from .config import HOME_DIR
|
|
21
|
+
from .httpapi import _SameOriginRedirect, _redact, _resolve_secrets, validate_arguments
|
|
22
|
+
|
|
23
|
+
USAGE_FILE = HOME_DIR / "usage.json"
|
|
24
|
+
DORMANT_AFTER = 30 * 24 * 60 * 60
|
|
25
|
+
MAX_MCP_RESPONSE = 5_000_000
|
|
26
|
+
|
|
27
|
+
MCP_SEARCH_SCHEMA = {
|
|
28
|
+
"type": "function",
|
|
29
|
+
"function": {
|
|
30
|
+
"name": "mcp_search",
|
|
31
|
+
"description": "Find MCP tools by describing the action you need. Forge will load the exact "
|
|
32
|
+
"tool schema before it can be called; do not guess tool names or parameters.",
|
|
33
|
+
"parameters": {"type": "object", "properties": {
|
|
34
|
+
"query": {"type": "string"},
|
|
35
|
+
}, "required": ["query"]},
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class UsageStore:
|
|
41
|
+
_registry_lock = threading.Lock()
|
|
42
|
+
_registry = {}
|
|
43
|
+
|
|
44
|
+
def __init__(self, path=None, warn=None):
|
|
45
|
+
self.path = pathlib.Path(path or USAGE_FILE).resolve()
|
|
46
|
+
self.warn = warn or (lambda _: None)
|
|
47
|
+
migrated = False
|
|
48
|
+
with self._registry_lock:
|
|
49
|
+
shared = self._registry.get(self.path)
|
|
50
|
+
if shared is None:
|
|
51
|
+
try:
|
|
52
|
+
data = json.loads(self.path.read_text(encoding="utf-8"))
|
|
53
|
+
if not isinstance(data, dict):
|
|
54
|
+
data = {}
|
|
55
|
+
except (OSError, ValueError):
|
|
56
|
+
data = {}
|
|
57
|
+
if not isinstance(data.get("records"), dict):
|
|
58
|
+
data["records"] = {}
|
|
59
|
+
if not isinstance(data.get("servers"), dict):
|
|
60
|
+
data["servers"] = {}
|
|
61
|
+
from . import keyvault
|
|
62
|
+
records = data["records"]
|
|
63
|
+
for name, row in list(records.items()):
|
|
64
|
+
if not isinstance(name, str) or not isinstance(row, dict):
|
|
65
|
+
continue
|
|
66
|
+
parts = name.split(":", 2)
|
|
67
|
+
if len(parts) != 3 or parts[0] != "key" or parts[2].startswith("sha256-"):
|
|
68
|
+
continue
|
|
69
|
+
safe_name = f"key:{parts[1]}:{keyvault.fingerprint(parts[2])}"
|
|
70
|
+
previous = records.get(safe_name)
|
|
71
|
+
if isinstance(previous, dict):
|
|
72
|
+
for field in ("calls", "failures", "latency_total"):
|
|
73
|
+
previous[field] = previous.get(field, 0) + row.get(field, 0)
|
|
74
|
+
previous["last_used"] = max(previous.get("last_used", 0), row.get("last_used", 0))
|
|
75
|
+
else:
|
|
76
|
+
records[safe_name] = row
|
|
77
|
+
del records[name]
|
|
78
|
+
migrated = True
|
|
79
|
+
shared = (threading.Lock(), data)
|
|
80
|
+
self._registry[self.path] = shared
|
|
81
|
+
self.lock, self.data = shared
|
|
82
|
+
if migrated:
|
|
83
|
+
self._save()
|
|
84
|
+
|
|
85
|
+
def record(self, category: str, name: str, success: bool = True, latency: float = 0.0) -> None:
|
|
86
|
+
with self.lock:
|
|
87
|
+
now = time.time()
|
|
88
|
+
key = f"{category}:{name}"
|
|
89
|
+
row = self.data["records"].setdefault(key, {
|
|
90
|
+
"calls": 0, "failures": 0, "latency_total": 0.0, "last_used": 0.0,
|
|
91
|
+
"consecutive_failures": 0, "recent_outcomes": [],
|
|
92
|
+
})
|
|
93
|
+
row["calls"] += 1
|
|
94
|
+
row["failures"] += not success
|
|
95
|
+
row["consecutive_failures"] = 0 if success else row.get("consecutive_failures", 0) + 1
|
|
96
|
+
recent = row.setdefault("recent_outcomes", [])
|
|
97
|
+
recent.append(bool(success))
|
|
98
|
+
del recent[:-20]
|
|
99
|
+
row["latency_total"] += max(0.0, float(latency))
|
|
100
|
+
row["last_used"] = now
|
|
101
|
+
self._save()
|
|
102
|
+
|
|
103
|
+
def get(self, category: str, name: str) -> dict:
|
|
104
|
+
with self.lock:
|
|
105
|
+
return dict(self.data["records"].get(f"{category}:{name}", {}))
|
|
106
|
+
|
|
107
|
+
def mark_seen(self, category: str, name: str) -> dict:
|
|
108
|
+
return self.mark_seen_many(category, [name]).get(name, {})
|
|
109
|
+
|
|
110
|
+
def mark_seen_many(self, category: str, names: list[str]) -> dict[str, dict]:
|
|
111
|
+
with self.lock:
|
|
112
|
+
now, changed, rows = time.time(), False, {}
|
|
113
|
+
for name in names:
|
|
114
|
+
row = self.data["records"].setdefault(f"{category}:{name}", {
|
|
115
|
+
"calls": 0, "failures": 0, "latency_total": 0.0, "last_used": 0.0,
|
|
116
|
+
"consecutive_failures": 0, "recent_outcomes": [],
|
|
117
|
+
})
|
|
118
|
+
if not row.get("first_seen"):
|
|
119
|
+
row["first_seen"] = now
|
|
120
|
+
changed = True
|
|
121
|
+
rows[name] = dict(row)
|
|
122
|
+
if changed:
|
|
123
|
+
self._save()
|
|
124
|
+
return rows
|
|
125
|
+
|
|
126
|
+
def recent_failures(self, category: str, name: str, limit: int = 20) -> int:
|
|
127
|
+
row = self.get(category, name)
|
|
128
|
+
return sum(not success for success in row.get("recent_outcomes", [])[-limit:])
|
|
129
|
+
|
|
130
|
+
def server_state(self, name: str) -> dict:
|
|
131
|
+
with self.lock:
|
|
132
|
+
return dict(self.data["servers"].get(name, {}))
|
|
133
|
+
|
|
134
|
+
def update_server(self, name: str, **fields) -> None:
|
|
135
|
+
with self.lock:
|
|
136
|
+
self.data["servers"].setdefault(name, {}).update(fields)
|
|
137
|
+
self._save()
|
|
138
|
+
|
|
139
|
+
def report(self) -> list[tuple[str, dict]]:
|
|
140
|
+
with self.lock:
|
|
141
|
+
return [(key, dict(row)) for key, row in sorted(self.data["records"].items())]
|
|
142
|
+
|
|
143
|
+
def _save(self):
|
|
144
|
+
try:
|
|
145
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
146
|
+
temp = self.path.with_suffix(self.path.suffix + ".tmp")
|
|
147
|
+
temp.write_text(json.dumps(self.data, indent=2), encoding="utf-8")
|
|
148
|
+
temp.replace(self.path)
|
|
149
|
+
except OSError as e:
|
|
150
|
+
self.warn(f"could not save Forge usage: {e}")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class _Stdio:
|
|
154
|
+
def __init__(self, command, env, cwd, timeout=30):
|
|
155
|
+
self.timeout = timeout
|
|
156
|
+
self.lock = threading.Lock()
|
|
157
|
+
self.responses = queue.Queue()
|
|
158
|
+
self.next_id = 0
|
|
159
|
+
self.proc = subprocess.Popen(command, cwd=str(cwd), env=env, stdin=subprocess.PIPE,
|
|
160
|
+
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
|
161
|
+
text=True, encoding="utf-8", errors="replace", bufsize=1)
|
|
162
|
+
self.reader = threading.Thread(target=self._read, daemon=True, name="forge-mcp-reader")
|
|
163
|
+
self.reader.start()
|
|
164
|
+
|
|
165
|
+
def _read(self):
|
|
166
|
+
try:
|
|
167
|
+
for line in self.proc.stdout:
|
|
168
|
+
try:
|
|
169
|
+
message = json.loads(line)
|
|
170
|
+
except ValueError:
|
|
171
|
+
continue
|
|
172
|
+
if "id" in message:
|
|
173
|
+
self.responses.put(message)
|
|
174
|
+
except (OSError, ValueError):
|
|
175
|
+
pass
|
|
176
|
+
|
|
177
|
+
def request(self, method, params=None, notify=False):
|
|
178
|
+
with self.lock:
|
|
179
|
+
self.next_id += 1
|
|
180
|
+
request_id = self.next_id
|
|
181
|
+
message = {"jsonrpc": "2.0", "method": method}
|
|
182
|
+
if params is not None:
|
|
183
|
+
message["params"] = params
|
|
184
|
+
if not notify:
|
|
185
|
+
message["id"] = request_id
|
|
186
|
+
self.proc.stdin.write(json.dumps(message, separators=(",", ":")) + "\n")
|
|
187
|
+
self.proc.stdin.flush()
|
|
188
|
+
if notify:
|
|
189
|
+
return {}
|
|
190
|
+
end = time.time() + self.timeout
|
|
191
|
+
while time.time() < end:
|
|
192
|
+
try:
|
|
193
|
+
response = self.responses.get(timeout=max(0.01, end - time.time()))
|
|
194
|
+
except queue.Empty:
|
|
195
|
+
break
|
|
196
|
+
if response.get("id") == request_id:
|
|
197
|
+
if "error" in response:
|
|
198
|
+
raise RuntimeError(str(response["error"])[:500])
|
|
199
|
+
return response.get("result") or {}
|
|
200
|
+
raise TimeoutError(f"MCP server did not answer {method} within {self.timeout}s")
|
|
201
|
+
|
|
202
|
+
def close(self):
|
|
203
|
+
if self.proc.poll() is not None:
|
|
204
|
+
return
|
|
205
|
+
try:
|
|
206
|
+
self.proc.stdin.close()
|
|
207
|
+
self.proc.terminate()
|
|
208
|
+
self.proc.wait(timeout=2)
|
|
209
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
210
|
+
try:
|
|
211
|
+
self.proc.kill()
|
|
212
|
+
except OSError:
|
|
213
|
+
pass
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class _StreamableHTTP:
|
|
217
|
+
def __init__(self, url, headers, timeout=30):
|
|
218
|
+
self.url = url
|
|
219
|
+
self.headers = headers
|
|
220
|
+
self.timeout = timeout
|
|
221
|
+
self.session_id = ""
|
|
222
|
+
self.protocol_version = ""
|
|
223
|
+
self.next_id = 0
|
|
224
|
+
|
|
225
|
+
def request(self, method, params=None, notify=False):
|
|
226
|
+
self.next_id += 1
|
|
227
|
+
message = {"jsonrpc": "2.0", "method": method}
|
|
228
|
+
if params is not None:
|
|
229
|
+
message["params"] = params
|
|
230
|
+
if not notify:
|
|
231
|
+
message["id"] = self.next_id
|
|
232
|
+
headers = dict(self.headers)
|
|
233
|
+
headers.update({"Content-Type": "application/json", "Accept": "application/json, text/event-stream"})
|
|
234
|
+
if self.session_id:
|
|
235
|
+
headers["Mcp-Session-Id"] = self.session_id
|
|
236
|
+
if self.protocol_version:
|
|
237
|
+
headers["Mcp-Protocol-Version"] = self.protocol_version
|
|
238
|
+
request = urllib.request.Request(self.url, json.dumps(message).encode(), headers, method="POST")
|
|
239
|
+
try:
|
|
240
|
+
opener = urllib.request.build_opener(_SameOriginRedirect())
|
|
241
|
+
with opener.open(request, timeout=self.timeout) as response:
|
|
242
|
+
self.session_id = response.headers.get("Mcp-Session-Id", self.session_id)
|
|
243
|
+
raw = response.read(MAX_MCP_RESPONSE + 1)
|
|
244
|
+
if len(raw) > MAX_MCP_RESPONSE:
|
|
245
|
+
raise ValueError("MCP response exceeds the 5 MB limit")
|
|
246
|
+
content_type = response.headers.get("Content-Type", "")
|
|
247
|
+
except urllib.error.HTTPError as e:
|
|
248
|
+
if notify and e.code in (202, 204):
|
|
249
|
+
return {}
|
|
250
|
+
self.session_id = e.headers.get("Mcp-Session-Id", self.session_id)
|
|
251
|
+
raw, content_type = e.read(MAX_MCP_RESPONSE + 1), e.headers.get("Content-Type", "")
|
|
252
|
+
if notify:
|
|
253
|
+
return {}
|
|
254
|
+
text = raw.decode("utf-8", "replace")
|
|
255
|
+
if "text/event-stream" in content_type:
|
|
256
|
+
records = [line[5:].strip() for line in text.splitlines() if line.startswith("data:")]
|
|
257
|
+
text = next((line for line in reversed(records) if line), "")
|
|
258
|
+
response = json.loads(text)
|
|
259
|
+
if response.get("id") != message["id"]:
|
|
260
|
+
raise ValueError("MCP server response id did not match the request")
|
|
261
|
+
if "error" in response:
|
|
262
|
+
raise RuntimeError(str(response["error"])[:500])
|
|
263
|
+
return response.get("result") or {}
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class MCPManager:
|
|
267
|
+
def __init__(self, tools, root: pathlib.Path, usage: UsageStore | None = None,
|
|
268
|
+
home_dir: pathlib.Path | None = None, forge_dir: pathlib.Path | None = None):
|
|
269
|
+
self.tools = tools
|
|
270
|
+
self.root = pathlib.Path(root).resolve()
|
|
271
|
+
self.home_dir = pathlib.Path(home_dir or pathlib.Path.home())
|
|
272
|
+
self.forge_dir = pathlib.Path(forge_dir or HOME_DIR)
|
|
273
|
+
self.usage = usage or UsageStore(warn=tools.ui.warn)
|
|
274
|
+
self.configs, self.config_errors = self._load_configs()
|
|
275
|
+
self.clients = {}
|
|
276
|
+
self.secrets = {}
|
|
277
|
+
self.tools_by_id: dict[str, dict] = {}
|
|
278
|
+
self.exposed: set[str] = set()
|
|
279
|
+
self.matchers = {}
|
|
280
|
+
self._closed = False
|
|
281
|
+
atexit.register(self.close)
|
|
282
|
+
self._startup_notice()
|
|
283
|
+
|
|
284
|
+
def _load_configs(self):
|
|
285
|
+
errors = []
|
|
286
|
+
claude_file = self.home_dir / ".claude.json"
|
|
287
|
+
claude = self._read_json(claude_file, errors)
|
|
288
|
+
configs = self._servers(claude.get("mcpServers"), claude_file, errors)
|
|
289
|
+
disabled = set()
|
|
290
|
+
projects = claude.get("projects") if isinstance(claude.get("projects"), dict) else {}
|
|
291
|
+
root_key = str(self.root)
|
|
292
|
+
for key, value in projects.items():
|
|
293
|
+
if self._same_path(key, root_key) and isinstance(value, dict):
|
|
294
|
+
configs.update(self._servers(value.get("mcpServers"), claude_file, errors))
|
|
295
|
+
self._disable(disabled, value.get("disabledMcpServers"))
|
|
296
|
+
forge_file = self.forge_dir / "mcp.json"
|
|
297
|
+
forge = self._read_json(forge_file, errors)
|
|
298
|
+
configs.update(self._servers(forge.get("mcpServers"), forge_file, errors))
|
|
299
|
+
project_file = self.root / ".mcp.json"
|
|
300
|
+
project = self._read_json(project_file, errors)
|
|
301
|
+
configs.update(self._servers(project.get("mcpServers"), project_file, errors))
|
|
302
|
+
self._disable(disabled, project.get("disabledMcpServers"))
|
|
303
|
+
self._disable(disabled, forge.get("disabledMcpServers"))
|
|
304
|
+
for name in disabled:
|
|
305
|
+
configs.pop(name, None)
|
|
306
|
+
normalized = {}
|
|
307
|
+
for name, config in configs.items():
|
|
308
|
+
if isinstance(config, dict):
|
|
309
|
+
normalized[str(name)] = {"config": config, "source": self._config_source(name)}
|
|
310
|
+
return normalized, errors
|
|
311
|
+
|
|
312
|
+
@staticmethod
|
|
313
|
+
def _servers(value, path, errors):
|
|
314
|
+
if value is None:
|
|
315
|
+
return {}
|
|
316
|
+
if isinstance(value, dict):
|
|
317
|
+
return value
|
|
318
|
+
errors.append(f"{path}: mcpServers must be a JSON object")
|
|
319
|
+
return {}
|
|
320
|
+
|
|
321
|
+
@staticmethod
|
|
322
|
+
def _disable(disabled, value):
|
|
323
|
+
if isinstance(value, (list, tuple, set)):
|
|
324
|
+
disabled.update(str(name) for name in value)
|
|
325
|
+
|
|
326
|
+
def _read_json(self, path, errors):
|
|
327
|
+
try:
|
|
328
|
+
if not path.is_file():
|
|
329
|
+
return {}
|
|
330
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
331
|
+
if not isinstance(data, dict):
|
|
332
|
+
errors.append(f"{path}: expected a JSON object")
|
|
333
|
+
return {}
|
|
334
|
+
return data
|
|
335
|
+
except (OSError, ValueError) as e:
|
|
336
|
+
errors.append(f"{path}: {type(e).__name__}: {e}")
|
|
337
|
+
return {}
|
|
338
|
+
|
|
339
|
+
@staticmethod
|
|
340
|
+
def _same_path(a, b):
|
|
341
|
+
try:
|
|
342
|
+
return os.path.normcase(str(pathlib.Path(a).resolve())) == \
|
|
343
|
+
os.path.normcase(str(pathlib.Path(b).resolve()))
|
|
344
|
+
except OSError:
|
|
345
|
+
return False
|
|
346
|
+
|
|
347
|
+
def _config_source(self, name):
|
|
348
|
+
for path in (self.root / ".mcp.json", self.forge_dir / "mcp.json", self.home_dir / ".claude.json"):
|
|
349
|
+
try:
|
|
350
|
+
if path.is_file():
|
|
351
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
352
|
+
if name in (data.get("mcpServers") or {}):
|
|
353
|
+
return str(path)
|
|
354
|
+
for project in (data.get("projects") or {}).values():
|
|
355
|
+
if name in (project.get("mcpServers") or {}):
|
|
356
|
+
return str(path)
|
|
357
|
+
except (OSError, ValueError, AttributeError):
|
|
358
|
+
continue
|
|
359
|
+
return "configuration"
|
|
360
|
+
|
|
361
|
+
@staticmethod
|
|
362
|
+
def _slug(value):
|
|
363
|
+
return re.sub(r"_+", "_", re.sub(r"[^a-zA-Z0-9_]+", "_", value)).strip("_").lower() or "server"
|
|
364
|
+
|
|
365
|
+
def _fingerprint(self, config):
|
|
366
|
+
return hashlib.sha256(json.dumps(config, sort_keys=True, default=str).encode()).hexdigest()
|
|
367
|
+
|
|
368
|
+
def _server_dormant(self, name, config):
|
|
369
|
+
state = self.usage.server_state(name)
|
|
370
|
+
fingerprint = self._fingerprint(config)
|
|
371
|
+
failures = int(state.get("failures", 0))
|
|
372
|
+
if state.get("config_hash") != fingerprint:
|
|
373
|
+
if failures:
|
|
374
|
+
self.usage.update_server(name, failures=0, config_hash=fingerprint)
|
|
375
|
+
failures = 0
|
|
376
|
+
last_used = float(state.get("last_used", 0))
|
|
377
|
+
return failures >= 3 or bool(last_used and time.time() - last_used >= DORMANT_AFTER)
|
|
378
|
+
|
|
379
|
+
def _startup_notice(self):
|
|
380
|
+
unused = []
|
|
381
|
+
for name in self.configs:
|
|
382
|
+
state = self.usage.server_state(name)
|
|
383
|
+
last = float(state.get("last_used", 0))
|
|
384
|
+
if last and time.time() - last >= DORMANT_AFTER:
|
|
385
|
+
unused.append(name)
|
|
386
|
+
if unused:
|
|
387
|
+
self.tools.ui.info(f"Resting unused MCP servers (30d): {', '.join(sorted(unused)[:8])}")
|
|
388
|
+
|
|
389
|
+
def _server_description(self, name, row):
|
|
390
|
+
config = row["config"]
|
|
391
|
+
return str(config.get("description") or name)[:160]
|
|
392
|
+
|
|
393
|
+
def _server_matches(self, query):
|
|
394
|
+
docs = {}
|
|
395
|
+
for name, row in self.configs.items():
|
|
396
|
+
docs[name] = skills.Skill(name, self._server_description(name, row),
|
|
397
|
+
pathlib.Path(name), "mcp")
|
|
398
|
+
if not docs or not query:
|
|
399
|
+
return []
|
|
400
|
+
matcher = skills.Matcher(docs)
|
|
401
|
+
return [(score, skill.name) for score, skill in matcher.scores(query)
|
|
402
|
+
if score >= matcher.min_score * 0.35]
|
|
403
|
+
|
|
404
|
+
def schemas(self, query=""):
|
|
405
|
+
matching = self._server_matches(query)
|
|
406
|
+
for _, name in matching[:3]:
|
|
407
|
+
self._load_tools(name)
|
|
408
|
+
out = []
|
|
409
|
+
for name, desc in self.server_descriptions().items():
|
|
410
|
+
if not self._server_dormant(name, self.configs[name]["config"]):
|
|
411
|
+
out.append(f"{name}: {desc}")
|
|
412
|
+
search_schema = json.loads(json.dumps(MCP_SEARCH_SCHEMA))
|
|
413
|
+
search_schema["function"]["description"] += "\nConfigured servers:\n" + "\n".join(out)
|
|
414
|
+
schemas = [search_schema] if self.configs else []
|
|
415
|
+
for tool_id in sorted(self.exposed):
|
|
416
|
+
entry = self.tools_by_id.get(tool_id)
|
|
417
|
+
if entry:
|
|
418
|
+
schemas.append(entry["schema"])
|
|
419
|
+
if query:
|
|
420
|
+
for name in list(self.clients):
|
|
421
|
+
matches = self._matching_tools(name, query)
|
|
422
|
+
for entry in matches[:5]:
|
|
423
|
+
self.exposed.add(entry["id"])
|
|
424
|
+
schemas.append(entry["schema"])
|
|
425
|
+
unique = {}
|
|
426
|
+
for schema in schemas:
|
|
427
|
+
unique[schema["function"]["name"]] = schema
|
|
428
|
+
return list(unique.values())
|
|
429
|
+
|
|
430
|
+
def server_descriptions(self):
|
|
431
|
+
return {name: self._server_description(name, row) for name, row in self.configs.items()}
|
|
432
|
+
|
|
433
|
+
def search(self, query):
|
|
434
|
+
matches = self._server_matches(query)
|
|
435
|
+
results = []
|
|
436
|
+
for _, server in matches[:3]:
|
|
437
|
+
error = self._load_tools(server)
|
|
438
|
+
if error:
|
|
439
|
+
results.append(f"{server}: {error}")
|
|
440
|
+
continue
|
|
441
|
+
for entry in self._matching_tools(server, query)[:8]:
|
|
442
|
+
self.exposed.add(entry["id"])
|
|
443
|
+
results.append(f"{entry['id']}: {entry['description']}")
|
|
444
|
+
if not results:
|
|
445
|
+
return "No MCP tool matched. Check the configured server names/descriptions or try a more specific query."
|
|
446
|
+
return "Exact MCP tools loaded (schemas available on the next step):\n" + "\n".join(
|
|
447
|
+
"- " + item for item in results)
|
|
448
|
+
|
|
449
|
+
def _matching_tools(self, server, query):
|
|
450
|
+
entries = [e for e in self.tools_by_id.values() if e["server"] == server]
|
|
451
|
+
if not entries:
|
|
452
|
+
return []
|
|
453
|
+
docs = {}
|
|
454
|
+
for entry in entries:
|
|
455
|
+
docs[entry["id"]] = skills.Skill(entry["id"],
|
|
456
|
+
entry["description"] + " " + entry["tool_name"],
|
|
457
|
+
pathlib.Path(entry["id"]), "mcp")
|
|
458
|
+
matcher = skills.Matcher(docs)
|
|
459
|
+
return [self.tools_by_id[skill.name] for score, skill in matcher.scores(query)
|
|
460
|
+
if score >= matcher.min_score * 0.35]
|
|
461
|
+
|
|
462
|
+
def _load_tools(self, name):
|
|
463
|
+
if name in self.clients:
|
|
464
|
+
return ""
|
|
465
|
+
row = self.configs[name]
|
|
466
|
+
config = row["config"]
|
|
467
|
+
fingerprint = self._fingerprint(config)
|
|
468
|
+
state = self.usage.server_state(name)
|
|
469
|
+
if state.get("config_hash") == fingerprint and int(state.get("failures", 0)) >= 3:
|
|
470
|
+
return "dormant after three startup failures; edit its configuration to retry"
|
|
471
|
+
client = None
|
|
472
|
+
try:
|
|
473
|
+
client = self._connect(name, row)
|
|
474
|
+
init = {"capabilities": {}, "clientInfo": {"name": "Forge", "version": "0.1"}}
|
|
475
|
+
try:
|
|
476
|
+
initialized = client.request("initialize", {"protocolVersion": "2025-03-26", **init})
|
|
477
|
+
except RuntimeError as e:
|
|
478
|
+
if "protocol" not in str(e).lower() and "version" not in str(e).lower():
|
|
479
|
+
raise
|
|
480
|
+
initialized = client.request("initialize", {"protocolVersion": "2024-11-05", **init})
|
|
481
|
+
if isinstance(client, _StreamableHTTP):
|
|
482
|
+
client.protocol_version = str(initialized.get("protocolVersion") or "2025-03-26")
|
|
483
|
+
client.request("notifications/initialized", notify=True)
|
|
484
|
+
response = client.request("tools/list", {})
|
|
485
|
+
if not isinstance(response.get("tools"), list):
|
|
486
|
+
raise ValueError("MCP server returned no tools list")
|
|
487
|
+
all_tools = list(response["tools"])
|
|
488
|
+
cursor = response.get("nextCursor")
|
|
489
|
+
seen_cursors = set()
|
|
490
|
+
while cursor and len(seen_cursors) < 100:
|
|
491
|
+
if not isinstance(cursor, str):
|
|
492
|
+
raise ValueError("MCP server returned an invalid pagination cursor")
|
|
493
|
+
if cursor in seen_cursors:
|
|
494
|
+
break
|
|
495
|
+
seen_cursors.add(cursor)
|
|
496
|
+
page = client.request("tools/list", {"cursor": cursor})
|
|
497
|
+
if not isinstance(page.get("tools"), list):
|
|
498
|
+
raise ValueError("MCP server returned an invalid tools page")
|
|
499
|
+
all_tools.extend(page["tools"])
|
|
500
|
+
cursor = page.get("nextCursor")
|
|
501
|
+
self.clients[name] = client
|
|
502
|
+
for tool in all_tools:
|
|
503
|
+
self._register(name, tool)
|
|
504
|
+
schema_chars = sum(len(json.dumps(entry["schema"], ensure_ascii=False))
|
|
505
|
+
for entry in self.tools_by_id.values() if entry["server"] == name)
|
|
506
|
+
self.usage.update_server(name, failures=0, config_hash=fingerprint)
|
|
507
|
+
self.usage.update_server(name, schema_chars=schema_chars)
|
|
508
|
+
return ""
|
|
509
|
+
except Exception as e:
|
|
510
|
+
if isinstance(client, _Stdio):
|
|
511
|
+
client.close()
|
|
512
|
+
if isinstance(e, PermissionError):
|
|
513
|
+
return "MCP server startup was not approved"
|
|
514
|
+
self.usage.update_server(name, failures=int(state.get("failures", 0)) + 1,
|
|
515
|
+
config_hash=fingerprint, last_error=type(e).__name__)
|
|
516
|
+
return f"could not start MCP server ({type(e).__name__}: " + \
|
|
517
|
+
_redact(str(e)[:180], self.secrets.get(name, set())) + ")"
|
|
518
|
+
|
|
519
|
+
def _connect(self, name, row):
|
|
520
|
+
config = row["config"]
|
|
521
|
+
kind = str(config.get("type") or "").lower()
|
|
522
|
+
used = set()
|
|
523
|
+
if kind in ("http", "streamable-http") or config.get("url"):
|
|
524
|
+
url = _resolve_secrets(str(config.get("url") or ""), used)
|
|
525
|
+
if urllib.parse.urlsplit(url).scheme not in ("http", "https"):
|
|
526
|
+
raise ValueError("MCP HTTP server needs an http(s) URL")
|
|
527
|
+
headers = _resolve_secrets(config.get("headers") or {}, used)
|
|
528
|
+
if not isinstance(headers, dict):
|
|
529
|
+
raise ValueError("MCP HTTP headers must be an object")
|
|
530
|
+
used.update(str(v) for key, v in headers.items()
|
|
531
|
+
if v and re.search(r"(auth|token|key|secret|cookie|credential)", str(key), re.I))
|
|
532
|
+
self.secrets[name] = used
|
|
533
|
+
timeout = max(1, min(int(config.get("timeout", 30)), 600))
|
|
534
|
+
return _StreamableHTTP(url, headers, timeout)
|
|
535
|
+
|
|
536
|
+
command = config.get("command")
|
|
537
|
+
args = config.get("args") or []
|
|
538
|
+
if isinstance(command, list):
|
|
539
|
+
args = command[1:] + list(args)
|
|
540
|
+
command = command[0] if command else ""
|
|
541
|
+
if not isinstance(command, str) or not command.strip():
|
|
542
|
+
raise ValueError("MCP stdio server needs a command")
|
|
543
|
+
argv = [command.strip()] + [str(v) for v in args]
|
|
544
|
+
env = os.environ.copy()
|
|
545
|
+
env_values = config.get("env") or {}
|
|
546
|
+
env.update({str(k): str(_resolve_secrets(str(v), used)) for k, v in env_values.items()})
|
|
547
|
+
for key, value in env_values.items():
|
|
548
|
+
if re.search(r"(key|token|secret|pass|auth|credential)", str(key), re.I) and value:
|
|
549
|
+
used.add(str(env[str(key)]))
|
|
550
|
+
self.secrets[name] = used
|
|
551
|
+
workdir = pathlib.Path(os.path.expandvars(os.path.expanduser(
|
|
552
|
+
str(config.get("cwd") or self.root)))).resolve()
|
|
553
|
+
if not workdir.is_dir():
|
|
554
|
+
raise ValueError("MCP working directory does not exist")
|
|
555
|
+
why = "starts a configured MCP process"
|
|
556
|
+
safe_command = pathlib.Path(argv[0]).name
|
|
557
|
+
denied = self.tools._approve("mcp_server", f"Start MCP server {name}",
|
|
558
|
+
f"Configured command: {safe_command} (from {row['source']})",
|
|
559
|
+
command="", why=why)
|
|
560
|
+
if denied:
|
|
561
|
+
raise PermissionError("server startup was not approved")
|
|
562
|
+
return _Stdio(argv, env, workdir, max(1, min(int(config.get("timeout", 30)), 600)))
|
|
563
|
+
|
|
564
|
+
def _register(self, server, tool):
|
|
565
|
+
if not isinstance(tool, dict) or not isinstance(tool.get("name"), str):
|
|
566
|
+
return
|
|
567
|
+
original = tool["name"]
|
|
568
|
+
head = f"mcp__{self._slug(server)[:20]}__"
|
|
569
|
+
tool_slug = self._slug(original)
|
|
570
|
+
max_tool_len = 64 - len(head)
|
|
571
|
+
if len(tool_slug) > max_tool_len:
|
|
572
|
+
suffix = "_" + hashlib.sha1(f"{server}:{original}".encode()).hexdigest()[:6]
|
|
573
|
+
tool_slug = tool_slug[:max_tool_len - len(suffix)] + suffix
|
|
574
|
+
base = head + tool_slug
|
|
575
|
+
tool_id = base
|
|
576
|
+
if tool_id in self.tools_by_id:
|
|
577
|
+
suffix = "__" + hashlib.sha1(f"{server}:{original}".encode()).hexdigest()[:6]
|
|
578
|
+
tool_id = tool_id[:64 - len(suffix)] + suffix
|
|
579
|
+
input_schema = copy.deepcopy(tool.get("inputSchema")) \
|
|
580
|
+
if isinstance(tool.get("inputSchema"), dict) else {"type": "object", "properties": {}}
|
|
581
|
+
self._strict_schema(input_schema)
|
|
582
|
+
schema = {"type": "function", "function": {
|
|
583
|
+
"name": tool_id,
|
|
584
|
+
"description": str(tool.get("description") or f"{server} MCP tool {original}")[:1200],
|
|
585
|
+
"parameters": input_schema,
|
|
586
|
+
}}
|
|
587
|
+
self.tools_by_id[tool_id] = {"id": tool_id, "server": server, "tool_name": original,
|
|
588
|
+
"description": schema["function"]["description"],
|
|
589
|
+
"schema": schema, "annotations": tool.get("annotations") or {}}
|
|
590
|
+
|
|
591
|
+
@classmethod
|
|
592
|
+
def _strict_schema(cls, schema):
|
|
593
|
+
if not isinstance(schema, dict):
|
|
594
|
+
return
|
|
595
|
+
if schema.get("type") == "object":
|
|
596
|
+
schema.setdefault("additionalProperties", False)
|
|
597
|
+
for child in (schema.get("properties") or {}).values():
|
|
598
|
+
cls._strict_schema(child)
|
|
599
|
+
extra = schema.get("additionalProperties")
|
|
600
|
+
if isinstance(extra, dict):
|
|
601
|
+
cls._strict_schema(extra)
|
|
602
|
+
if isinstance(schema.get("items"), dict):
|
|
603
|
+
cls._strict_schema(schema["items"])
|
|
604
|
+
|
|
605
|
+
def execute(self, name, args):
|
|
606
|
+
entry = self.tools_by_id.get(name)
|
|
607
|
+
if not entry or name not in self.exposed:
|
|
608
|
+
return f"Error: MCP tool {name!r} is not loaded from a server schema."
|
|
609
|
+
invalid = validate_arguments(entry["schema"]["function"]["parameters"], args)
|
|
610
|
+
if invalid:
|
|
611
|
+
return f"Error: {invalid}"
|
|
612
|
+
server = entry["server"]
|
|
613
|
+
annotations = entry["annotations"]
|
|
614
|
+
if annotations.get("readOnlyHint") is not True:
|
|
615
|
+
denied = self.tools._approve(
|
|
616
|
+
"mcp_tool", f"Run MCP tool {server}.{entry['tool_name']}",
|
|
617
|
+
"Arguments are omitted from this prompt to avoid displaying credentials or private data.",
|
|
618
|
+
why="MCP tool is not declared read-only")
|
|
619
|
+
if denied:
|
|
620
|
+
return denied
|
|
621
|
+
started = time.monotonic()
|
|
622
|
+
try:
|
|
623
|
+
result = self.clients[server].request("tools/call", {
|
|
624
|
+
"name": entry["tool_name"], "arguments": args,
|
|
625
|
+
})
|
|
626
|
+
content = result.get("content") or []
|
|
627
|
+
text = "\n".join(str(item.get("text", "")) for item in content
|
|
628
|
+
if isinstance(item, dict) and item.get("type") == "text")
|
|
629
|
+
if not text:
|
|
630
|
+
text = json.dumps(result, ensure_ascii=False)[:MAX_MCP_RESPONSE]
|
|
631
|
+
text = _redact(text, self.secrets.get(server, set()))
|
|
632
|
+
is_error = bool(result.get("isError"))
|
|
633
|
+
self.usage.record("mcp_tool", f"{server}/{entry['tool_name']}", not is_error,
|
|
634
|
+
time.monotonic() - started)
|
|
635
|
+
self.usage.update_server(server, last_used=time.time())
|
|
636
|
+
if is_error:
|
|
637
|
+
return "MCP tool reported an error:\n" + text
|
|
638
|
+
return text or "(MCP tool returned no text content)"
|
|
639
|
+
except Exception as e:
|
|
640
|
+
self.usage.record("mcp_tool", f"{server}/{entry['tool_name']}", False,
|
|
641
|
+
time.monotonic() - started)
|
|
642
|
+
self.usage.update_server(server, last_used=time.time())
|
|
643
|
+
return f"Error: MCP call failed ({type(e).__name__}: " + \
|
|
644
|
+
_redact(str(e)[:300], self.secrets.get(server, set())) + ")"
|
|
645
|
+
|
|
646
|
+
def report(self):
|
|
647
|
+
lines = []
|
|
648
|
+
now = time.time()
|
|
649
|
+
for name, row in sorted(self.configs.items()):
|
|
650
|
+
state = self.usage.server_state(name)
|
|
651
|
+
age = now - float(state.get("last_used", now))
|
|
652
|
+
dormant = age >= DORMANT_AFTER and state.get("last_used")
|
|
653
|
+
failed = int(state.get("failures", 0)) >= 3
|
|
654
|
+
status = "dormant after 3 failures" if failed else (
|
|
655
|
+
"resting unused (30d)" if dormant else "ready" if name in self.clients else "not started")
|
|
656
|
+
lines.append(f"{name}: {status}")
|
|
657
|
+
for source in self.config_errors:
|
|
658
|
+
lines.append("Config warning: " + source)
|
|
659
|
+
for key, row in self.usage.report():
|
|
660
|
+
lines.append(f"{key}: {row['calls']} calls, {row['failures']} failures, "
|
|
661
|
+
f"{row['latency_total']:.1f}s total")
|
|
662
|
+
deferred = sum(int(self.usage.server_state(name).get("schema_chars", 0))
|
|
663
|
+
for name in self.configs) // 4
|
|
664
|
+
if deferred:
|
|
665
|
+
lines.append(f"Deferred MCP schemas: about {deferred:,} tokens are not sent on unmatched requests")
|
|
666
|
+
return "\n".join(lines) or "No MCP servers or recorded Forge tool usage."
|
|
667
|
+
|
|
668
|
+
def close(self):
|
|
669
|
+
if self._closed:
|
|
670
|
+
return
|
|
671
|
+
self._closed = True
|
|
672
|
+
for client in self.clients.values():
|
|
673
|
+
if isinstance(client, _Stdio):
|
|
674
|
+
client.close()
|