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.
Files changed (113) hide show
  1. forgecli/__init__.py +2 -0
  2. forgecli/ads.py +322 -0
  3. forgecli/agent.py +1093 -0
  4. forgecli/art.py +574 -0
  5. forgecli/attach.py +246 -0
  6. forgecli/brief.py +215 -0
  7. forgecli/browsercheck.py +99 -0
  8. forgecli/checkpoints.py +81 -0
  9. forgecli/city.py +833 -0
  10. forgecli/cli.py +1668 -0
  11. forgecli/config.py +194 -0
  12. forgecli/courier.py +177 -0
  13. forgecli/courier_data.py +164 -0
  14. forgecli/courier_hd.py +431 -0
  15. forgecli/courier_hd_data.bin +0 -0
  16. forgecli/courier_hd_data.py +19 -0
  17. forgecli/ghlogin.py +76 -0
  18. forgecli/github.py +54 -0
  19. forgecli/guards.py +403 -0
  20. forgecli/hooks.py +50 -0
  21. forgecli/httpapi.py +434 -0
  22. forgecli/images.py +110 -0
  23. forgecli/keys.py +152 -0
  24. forgecli/keyvault.py +320 -0
  25. forgecli/mcp.py +674 -0
  26. forgecli/memory.py +42 -0
  27. forgecli/openapi.py +331 -0
  28. forgecli/parallel.py +308 -0
  29. forgecli/prices.py +72 -0
  30. forgecli/procs.py +144 -0
  31. forgecli/providers.py +758 -0
  32. forgecli/quality.py +192 -0
  33. forgecli/repomap.py +78 -0
  34. forgecli/roster.py +227 -0
  35. forgecli/safety.py +93 -0
  36. forgecli/sandbox.py +83 -0
  37. forgecli/sessions.py +97 -0
  38. forgecli/skills.py +446 -0
  39. forgecli/tools.py +1184 -0
  40. forgecli/ui.py +860 -0
  41. forgecli/worktrees.py +79 -0
  42. forgecommandline-0.8.0.data/data/skills/commit/SKILL.md +14 -0
  43. forgecommandline-0.8.0.data/data/skills/debug/SKILL.md +17 -0
  44. forgecommandline-0.8.0.data/data/skills/explain/SKILL.md +14 -0
  45. forgecommandline-0.8.0.data/data/skills/init/SKILL.md +19 -0
  46. forgecommandline-0.8.0.data/data/skills/library/LICENSES/agent-skills-MIT.txt +21 -0
  47. forgecommandline-0.8.0.data/data/skills/library/LICENSES/wondelai-MIT.txt +23 -0
  48. forgecommandline-0.8.0.data/data/skills/library/README.md +9 -0
  49. forgecommandline-0.8.0.data/data/skills/library/_references/accessibility-checklist.md +160 -0
  50. forgecommandline-0.8.0.data/data/skills/library/_references/definition-of-done.md +67 -0
  51. forgecommandline-0.8.0.data/data/skills/library/_references/observability-checklist.md +92 -0
  52. forgecommandline-0.8.0.data/data/skills/library/_references/orchestration-patterns.md +370 -0
  53. forgecommandline-0.8.0.data/data/skills/library/_references/performance-checklist.md +238 -0
  54. forgecommandline-0.8.0.data/data/skills/library/_references/security-checklist.md +248 -0
  55. forgecommandline-0.8.0.data/data/skills/library/_references/testing-patterns.md +235 -0
  56. forgecommandline-0.8.0.data/data/skills/library/api-and-interface-design/SKILL.md +367 -0
  57. forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/SKILL.md +279 -0
  58. forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/references/artifact-templates.md +157 -0
  59. forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/references/methods.md +87 -0
  60. forgecommandline-0.8.0.data/data/skills/library/awwwards-tier-web-application/SKILL.md +877 -0
  61. forgecommandline-0.8.0.data/data/skills/library/browser-testing-with-devtools/SKILL.md +317 -0
  62. forgecommandline-0.8.0.data/data/skills/library/ci-cd-and-automation/SKILL.md +390 -0
  63. forgecommandline-0.8.0.data/data/skills/library/code-review-and-quality/SKILL.md +398 -0
  64. forgecommandline-0.8.0.data/data/skills/library/code-simplification/SKILL.md +331 -0
  65. forgecommandline-0.8.0.data/data/skills/library/constraint-driven-development/SKILL.md +311 -0
  66. forgecommandline-0.8.0.data/data/skills/library/constraint-driven-development/references/floor-guard.md +182 -0
  67. forgecommandline-0.8.0.data/data/skills/library/context-engineering/SKILL.md +353 -0
  68. forgecommandline-0.8.0.data/data/skills/library/debugging-and-error-recovery/SKILL.md +300 -0
  69. forgecommandline-0.8.0.data/data/skills/library/deprecation-and-migration/SKILL.md +247 -0
  70. forgecommandline-0.8.0.data/data/skills/library/documentation-and-adrs/SKILL.md +297 -0
  71. forgecommandline-0.8.0.data/data/skills/library/doubt-driven-development/SKILL.md +243 -0
  72. forgecommandline-0.8.0.data/data/skills/library/frontend-ui-engineering/SKILL.md +340 -0
  73. forgecommandline-0.8.0.data/data/skills/library/git-workflow-and-versioning/SKILL.md +355 -0
  74. forgecommandline-0.8.0.data/data/skills/library/idea-refine/SKILL.md +193 -0
  75. forgecommandline-0.8.0.data/data/skills/library/idea-refine/examples.md +238 -0
  76. forgecommandline-0.8.0.data/data/skills/library/idea-refine/frameworks.md +99 -0
  77. forgecommandline-0.8.0.data/data/skills/library/idea-refine/refinement-criteria.md +113 -0
  78. forgecommandline-0.8.0.data/data/skills/library/idea-refine/scripts/idea-refine.sh +15 -0
  79. forgecommandline-0.8.0.data/data/skills/library/improve-code-quality/SKILL.md +275 -0
  80. forgecommandline-0.8.0.data/data/skills/library/improve-code-quality/references/artifact-templates.md +101 -0
  81. forgecommandline-0.8.0.data/data/skills/library/incremental-implementation/SKILL.md +249 -0
  82. forgecommandline-0.8.0.data/data/skills/library/interview-me/SKILL.md +235 -0
  83. forgecommandline-0.8.0.data/data/skills/library/observability-and-instrumentation/SKILL.md +238 -0
  84. forgecommandline-0.8.0.data/data/skills/library/performance-optimization/SKILL.md +267 -0
  85. forgecommandline-0.8.0.data/data/skills/library/performance-optimization/references/optimization-patterns.md +250 -0
  86. forgecommandline-0.8.0.data/data/skills/library/planning-and-task-breakdown/SKILL.md +257 -0
  87. forgecommandline-0.8.0.data/data/skills/library/remove-technical-debt/SKILL.md +203 -0
  88. forgecommandline-0.8.0.data/data/skills/library/remove-technical-debt/references/artifact-templates.md +79 -0
  89. forgecommandline-0.8.0.data/data/skills/library/security-and-hardening/SKILL.md +216 -0
  90. forgecommandline-0.8.0.data/data/skills/library/security-and-hardening/references/hardening-patterns.md +324 -0
  91. forgecommandline-0.8.0.data/data/skills/library/shipping-and-launch/SKILL.md +336 -0
  92. forgecommandline-0.8.0.data/data/skills/library/source-driven-development/SKILL.md +216 -0
  93. forgecommandline-0.8.0.data/data/skills/library/spec-driven-development/SKILL.md +259 -0
  94. forgecommandline-0.8.0.data/data/skills/library/system-design/SKILL.md +207 -0
  95. forgecommandline-0.8.0.data/data/skills/library/system-design/references/building-blocks.md +272 -0
  96. forgecommandline-0.8.0.data/data/skills/library/system-design/references/common-designs.md +257 -0
  97. forgecommandline-0.8.0.data/data/skills/library/system-design/references/database-scaling.md +295 -0
  98. forgecommandline-0.8.0.data/data/skills/library/system-design/references/estimation-numbers.md +250 -0
  99. forgecommandline-0.8.0.data/data/skills/library/system-design/references/four-step-process.md +286 -0
  100. forgecommandline-0.8.0.data/data/skills/library/system-design/references/reliability-operations.md +373 -0
  101. forgecommandline-0.8.0.data/data/skills/library/test-driven-development/SKILL.md +398 -0
  102. forgecommandline-0.8.0.data/data/skills/library/using-agent-skills/SKILL.md +192 -0
  103. forgecommandline-0.8.0.data/data/skills/review/SKILL.md +16 -0
  104. forgecommandline-0.8.0.data/data/skills/run/SKILL.md +17 -0
  105. forgecommandline-0.8.0.data/data/skills/security-review/SKILL.md +20 -0
  106. forgecommandline-0.8.0.data/data/skills/simplify/SKILL.md +18 -0
  107. forgecommandline-0.8.0.data/data/skills/test/SKILL.md +16 -0
  108. forgecommandline-0.8.0.data/data/skills/web-build/SKILL.md +25 -0
  109. forgecommandline-0.8.0.dist-info/METADATA +357 -0
  110. forgecommandline-0.8.0.dist-info/RECORD +113 -0
  111. forgecommandline-0.8.0.dist-info/WHEEL +5 -0
  112. forgecommandline-0.8.0.dist-info/entry_points.txt +2 -0
  113. 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()