misterdev 0.2.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.
- misterdev/__init__.py +3 -0
- misterdev/agent.py +2166 -0
- misterdev/agent_helpers.py +194 -0
- misterdev/analyzers/__init__.py +0 -0
- misterdev/analyzers/project_analyzer/__init__.py +246 -0
- misterdev/analyzers/project_analyzer/detection.py +146 -0
- misterdev/analyzers/project_analyzer/merge.py +137 -0
- misterdev/analyzers/project_analyzer/overview.py +312 -0
- misterdev/analyzers/project_analyzer/prompts.py +83 -0
- misterdev/cli.py +370 -0
- misterdev/config.py +521 -0
- misterdev/core/__init__.py +0 -0
- misterdev/core/audit.py +88 -0
- misterdev/core/config.py +10 -0
- misterdev/core/context/__init__.py +0 -0
- misterdev/core/context/change_tracker.py +218 -0
- misterdev/core/context/contracts/__init__.py +63 -0
- misterdev/core/context/contracts/_log.py +9 -0
- misterdev/core/context/contracts/_text.py +12 -0
- misterdev/core/context/contracts/extraction.py +30 -0
- misterdev/core/context/contracts/python_generic.py +42 -0
- misterdev/core/context/contracts/registry.py +127 -0
- misterdev/core/context/contracts/rust_line.py +225 -0
- misterdev/core/context/contracts/rust_tree_sitter.py +141 -0
- misterdev/core/context/lsp.py +174 -0
- misterdev/core/context/scratchpad.py +111 -0
- misterdev/core/context/topography/__init__.py +43 -0
- misterdev/core/context/topography/_log.py +10 -0
- misterdev/core/context/topography/cache.py +91 -0
- misterdev/core/context/topography/engine.py +172 -0
- misterdev/core/context/topography/graph.py +675 -0
- misterdev/core/context/topography/nodes.py +55 -0
- misterdev/core/context/topography/parsers.py +95 -0
- misterdev/core/context/topography/syntax.py +54 -0
- misterdev/core/economics/__init__.py +0 -0
- misterdev/core/economics/context_budget.py +175 -0
- misterdev/core/economics/embeddings.py +232 -0
- misterdev/core/economics/free_models.py +108 -0
- misterdev/core/economics/llm_cache.py +105 -0
- misterdev/core/economics/model_catalog.py +79 -0
- misterdev/core/economics/model_ledger.py +331 -0
- misterdev/core/economics/model_selector.py +281 -0
- misterdev/core/execution/__init__.py +0 -0
- misterdev/core/execution/bounded.py +50 -0
- misterdev/core/execution/container.py +221 -0
- misterdev/core/execution/error_classifier.py +366 -0
- misterdev/core/execution/error_resolver.py +201 -0
- misterdev/core/execution/governance.py +283 -0
- misterdev/core/execution/outcomes.py +50 -0
- misterdev/core/execution/progress.py +120 -0
- misterdev/core/execution/project.py +231 -0
- misterdev/core/execution/registry.py +97 -0
- misterdev/core/execution/runtime.py +279 -0
- misterdev/core/gitcmd.py +39 -0
- misterdev/core/integration/__init__.py +0 -0
- misterdev/core/integration/mcp.py +368 -0
- misterdev/core/integration/mcp_gather.py +186 -0
- misterdev/core/models.py +35 -0
- misterdev/core/modes.py +184 -0
- misterdev/core/planning/__init__.py +0 -0
- misterdev/core/planning/advisor.py +89 -0
- misterdev/core/planning/assessment.py +135 -0
- misterdev/core/planning/decomposer.py +387 -0
- misterdev/core/planning/metacognition.py +103 -0
- misterdev/core/planning/sovereign.py +308 -0
- misterdev/core/planning/targets.py +201 -0
- misterdev/core/reporting/__init__.py +0 -0
- misterdev/core/reporting/report.py +377 -0
- misterdev/core/reporting/report_view.py +151 -0
- misterdev/core/task.py +163 -0
- misterdev/core/verification/__init__.py +0 -0
- misterdev/core/verification/claim_verifier.py +210 -0
- misterdev/core/verification/critic.py +324 -0
- misterdev/core/verification/gatekeeper/__init__.py +631 -0
- misterdev/core/verification/gatekeeper/constants.py +138 -0
- misterdev/core/verification/gatekeeper/helpers.py +28 -0
- misterdev/core/verification/goal_check.py +219 -0
- misterdev/core/verification/independent.py +68 -0
- misterdev/core/verification/mutation_gate.py +221 -0
- misterdev/core/verification/preflight.py +95 -0
- misterdev/core/verification/spec_tests.py +175 -0
- misterdev/core/verification/validator.py +495 -0
- misterdev/core/verification/vision_verify.py +185 -0
- misterdev/core/verification/web_verify.py +408 -0
- misterdev/environments/__init__.py +0 -0
- misterdev/environments/base_env.py +18 -0
- misterdev/environments/container_env.py +87 -0
- misterdev/environments/venv_env.py +42 -0
- misterdev/llm/__init__.py +0 -0
- misterdev/llm/client/__init__.py +152 -0
- misterdev/llm/client/base.py +382 -0
- misterdev/llm/client/edits.py +70 -0
- misterdev/llm/client/embeddings.py +121 -0
- misterdev/llm/client/errors.py +134 -0
- misterdev/llm/client/providers.py +535 -0
- misterdev/llm/client/response.py +24 -0
- misterdev/llm/prompt_manager.py +82 -0
- misterdev/llm/responses/__init__.py +34 -0
- misterdev/llm/responses/apply.py +131 -0
- misterdev/llm/responses/json_extract.py +80 -0
- misterdev/llm/responses/models.py +43 -0
- misterdev/llm/responses/parsing.py +494 -0
- misterdev/logging_setup.py +20 -0
- misterdev/mcp_server.py +208 -0
- misterdev/nl_cli.py +149 -0
- misterdev/plugins.py +115 -0
- misterdev/py.typed +0 -0
- misterdev/task_executors/__init__.py +0 -0
- misterdev/task_executors/base_executor.py +10 -0
- misterdev/task_executors/markdown_plan_executor/__init__.py +90 -0
- misterdev/task_executors/markdown_plan_executor/commands_mixin.py +82 -0
- misterdev/task_executors/markdown_plan_executor/context_mixin.py +221 -0
- misterdev/task_executors/markdown_plan_executor/critic_spec_mixin.py +174 -0
- misterdev/task_executors/markdown_plan_executor/edits_mixin.py +251 -0
- misterdev/task_executors/markdown_plan_executor/execute_mixin.py +727 -0
- misterdev/task_executors/markdown_plan_executor/gates_mixin.py +203 -0
- misterdev/task_executors/markdown_plan_executor/git_mixin.py +219 -0
- misterdev/task_executors/markdown_plan_executor/helpers.py +521 -0
- misterdev/task_executors/markdown_plan_executor/llm_mixin.py +238 -0
- misterdev/task_executors/markdown_plan_executor/results_mixin.py +23 -0
- misterdev/tools/__init__.py +19 -0
- misterdev/tools/base_tool.py +14 -0
- misterdev/tools/command.py +75 -0
- misterdev/tools/file_io.py +69 -0
- misterdev/tools/formatter.py +26 -0
- misterdev/tools/git_tool.py +90 -0
- misterdev/utils/__init__.py +0 -0
- misterdev/utils/file_utils.py +169 -0
- misterdev/utils/process.py +23 -0
- misterdev-0.2.0.dist-info/METADATA +326 -0
- misterdev-0.2.0.dist-info/RECORD +136 -0
- misterdev-0.2.0.dist-info/WHEEL +5 -0
- misterdev-0.2.0.dist-info/entry_points.txt +3 -0
- misterdev-0.2.0.dist-info/licenses/COMMERCIAL_LICENSE.md +34 -0
- misterdev-0.2.0.dist-info/licenses/LICENSE +661 -0
- misterdev-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""Optional MCP (Model Context Protocol) tool-host substrate.
|
|
2
|
+
|
|
3
|
+
MCP servers expose tools the orchestrator could call (search a wiki, query a
|
|
4
|
+
database, run a domain-specific check) over a standard protocol. This module is
|
|
5
|
+
the *substrate*: it connects to the servers named in ``mcp.servers``, discovers
|
|
6
|
+
their tools, and can invoke one — nothing more. It does NOT turn the executor
|
|
7
|
+
into an autonomous tool-calling agent; the only integration is awareness
|
|
8
|
+
injection (the executor is told which tools exist). The agentic loop that would
|
|
9
|
+
let the model actually decide to call a tool mid-build is deliberately left as a
|
|
10
|
+
documented seam (see ``MCPManager.call_tool`` and the README).
|
|
11
|
+
|
|
12
|
+
It mirrors :mod:`misterdev.core.context.lsp`: strictly opt-in (off unless
|
|
13
|
+
``orchestrator.mcp_enabled`` and ``mcp.servers`` are set), best-effort, and every
|
|
14
|
+
operation is run in a daemon worker thread with a hard timeout so a missing SDK,
|
|
15
|
+
a server that fails to start, or one that hangs can NEVER block or slow a build.
|
|
16
|
+
A server that misbehaves is simply absent from the registry (logged at debug);
|
|
17
|
+
nothing here ever raises into the caller.
|
|
18
|
+
|
|
19
|
+
The official ``mcp`` Python SDK is imported lazily inside the worker so the
|
|
20
|
+
dependency is optional: without it the registry is empty and the manager is a
|
|
21
|
+
no-op. stdio (subprocess), streamable-http, and sse transports are supported;
|
|
22
|
+
the last two connect to a remote ``url`` with optional auth headers, which is how
|
|
23
|
+
misterdev reaches a hosted MCP gateway (e.g. Glama) that fronts many servers.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from contextlib import asynccontextmanager
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from typing import Any, Dict, List, Optional
|
|
29
|
+
|
|
30
|
+
from misterdev.core.execution.bounded import run_bounded
|
|
31
|
+
from misterdev.logging_setup import setup_logger
|
|
32
|
+
|
|
33
|
+
logger = setup_logger(__name__)
|
|
34
|
+
|
|
35
|
+
# Hard ceilings (seconds). Discovery may launch several subprocesses, so it gets
|
|
36
|
+
# a per-server budget; a single tool call is cheaper. Both are overridable via
|
|
37
|
+
# config but always bounded — there is no "wait forever" path.
|
|
38
|
+
_DEFAULT_CONNECT_TIMEOUT = 20.0
|
|
39
|
+
_DEFAULT_CALL_TIMEOUT = 30.0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class MCPTool:
|
|
44
|
+
"""One tool discovered from an MCP server.
|
|
45
|
+
|
|
46
|
+
``server`` is the configured server name (the routing key for
|
|
47
|
+
:meth:`MCPManager.call_tool`); ``input_schema`` is the JSON Schema the
|
|
48
|
+
server advertises for the tool's arguments (may be empty).
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
server: str
|
|
52
|
+
name: str
|
|
53
|
+
description: str = ""
|
|
54
|
+
input_schema: Dict[str, Any] = field(default_factory=dict)
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def qualified_name(self) -> str:
|
|
58
|
+
"""``server.tool`` — unique across servers, used in awareness text."""
|
|
59
|
+
return f"{self.server}.{self.name}"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _normalize_servers(servers: Any) -> List[Dict[str, Any]]:
|
|
63
|
+
"""Validate and normalize the ``mcp.servers`` config list.
|
|
64
|
+
|
|
65
|
+
Each entry must be a mapping with a non-empty ``name`` and ``command``;
|
|
66
|
+
malformed or duplicate-named entries are dropped with a warning rather than
|
|
67
|
+
raised, so a typo in one server never sinks the rest.
|
|
68
|
+
"""
|
|
69
|
+
out: List[Dict[str, Any]] = []
|
|
70
|
+
if not isinstance(servers, list):
|
|
71
|
+
if servers:
|
|
72
|
+
logger.warning("mcp.servers must be a list; ignoring.")
|
|
73
|
+
return out
|
|
74
|
+
seen: set = set()
|
|
75
|
+
for entry in servers:
|
|
76
|
+
if not isinstance(entry, dict):
|
|
77
|
+
logger.warning(f"Ignoring non-mapping mcp.servers entry: {entry!r}")
|
|
78
|
+
continue
|
|
79
|
+
name = entry.get("name")
|
|
80
|
+
transport = (entry.get("transport") or "stdio").lower()
|
|
81
|
+
remote = transport in ("http", "streamable-http", "streamable_http", "sse")
|
|
82
|
+
if remote:
|
|
83
|
+
# A remote server (e.g. a hosted MCP gateway) is addressed by url.
|
|
84
|
+
if not name or not entry.get("url"):
|
|
85
|
+
logger.warning(
|
|
86
|
+
f"Ignoring remote mcp server without name/url: {entry!r}"
|
|
87
|
+
)
|
|
88
|
+
continue
|
|
89
|
+
elif transport == "stdio":
|
|
90
|
+
if not name or not entry.get("command"):
|
|
91
|
+
logger.warning(
|
|
92
|
+
f"Ignoring stdio mcp server without name/command: {entry!r}"
|
|
93
|
+
)
|
|
94
|
+
continue
|
|
95
|
+
else:
|
|
96
|
+
logger.debug(
|
|
97
|
+
f"MCP server '{name}' uses unsupported transport {transport!r}; skipping."
|
|
98
|
+
)
|
|
99
|
+
continue
|
|
100
|
+
if name in seen:
|
|
101
|
+
logger.warning(
|
|
102
|
+
f"Duplicate mcp server name '{name}'; ignoring the later one."
|
|
103
|
+
)
|
|
104
|
+
continue
|
|
105
|
+
seen.add(name)
|
|
106
|
+
out.append(entry)
|
|
107
|
+
return out
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class MCPManager:
|
|
111
|
+
"""Connects to configured MCP servers and exposes their tools.
|
|
112
|
+
|
|
113
|
+
Discovery (``tools``) connects to every server once, lists its tools, and
|
|
114
|
+
caches the merged result; a server that fails contributes nothing. A
|
|
115
|
+
:meth:`call_tool` connects to the one named server, invokes the tool, and
|
|
116
|
+
returns its textual result (or ``None`` on any failure). Connections are
|
|
117
|
+
per-operation and short-lived: nothing async is held across the synchronous
|
|
118
|
+
orchestrator, which keeps the integration trivially safe.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
def __init__(
|
|
122
|
+
self,
|
|
123
|
+
servers: Any,
|
|
124
|
+
*,
|
|
125
|
+
connect_timeout: float = _DEFAULT_CONNECT_TIMEOUT,
|
|
126
|
+
call_timeout: float = _DEFAULT_CALL_TIMEOUT,
|
|
127
|
+
allow_tools: Optional[List[str]] = None,
|
|
128
|
+
):
|
|
129
|
+
self.servers = _normalize_servers(servers)
|
|
130
|
+
self.connect_timeout = float(connect_timeout)
|
|
131
|
+
self.call_timeout = float(call_timeout)
|
|
132
|
+
# Optional allowlist of callable tools (``server.tool`` or bare ``tool``).
|
|
133
|
+
# None means allow all; an empty/populated set restricts which tools a
|
|
134
|
+
# remote gateway (e.g. Glama, which can front many servers) may expose to
|
|
135
|
+
# the model — the model can only see and call what you allow.
|
|
136
|
+
self.allow_tools: Optional[set] = set(allow_tools) if allow_tools else None
|
|
137
|
+
self._tools: Optional[List[MCPTool]] = None # lazy, cached
|
|
138
|
+
|
|
139
|
+
def _allowed(self, server: str, name: str) -> bool:
|
|
140
|
+
if self.allow_tools is None:
|
|
141
|
+
return True
|
|
142
|
+
return f"{server}.{name}" in self.allow_tools or name in self.allow_tools
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def enabled(self) -> bool:
|
|
146
|
+
"""True when at least one usable server is configured."""
|
|
147
|
+
return bool(self.servers)
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def tools(self) -> List[MCPTool]:
|
|
151
|
+
"""Merged tool registry across all servers (discovered once, cached).
|
|
152
|
+
|
|
153
|
+
Best-effort and bounded: each server's discovery is timeout-guarded, so
|
|
154
|
+
a slow or broken server is skipped rather than blocking the rest. Always
|
|
155
|
+
returns a list (possibly empty); never raises.
|
|
156
|
+
"""
|
|
157
|
+
if self._tools is None:
|
|
158
|
+
self._tools = self._discover_all()
|
|
159
|
+
return self._tools
|
|
160
|
+
|
|
161
|
+
def _discover_all(self) -> List[MCPTool]:
|
|
162
|
+
tools: List[MCPTool] = []
|
|
163
|
+
for server in self.servers:
|
|
164
|
+
name = server["name"]
|
|
165
|
+
discovered = run_bounded(
|
|
166
|
+
lambda s=server: _list_tools(s),
|
|
167
|
+
self.connect_timeout,
|
|
168
|
+
default=[],
|
|
169
|
+
what=f"MCP tool discovery ({name})",
|
|
170
|
+
)
|
|
171
|
+
for t in discovered:
|
|
172
|
+
tool_name = t.get("name", "")
|
|
173
|
+
if not self._allowed(name, tool_name):
|
|
174
|
+
continue
|
|
175
|
+
tools.append(
|
|
176
|
+
MCPTool(
|
|
177
|
+
server=name,
|
|
178
|
+
name=tool_name,
|
|
179
|
+
description=t.get("description") or "",
|
|
180
|
+
input_schema=t.get("input_schema") or {},
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
if tools:
|
|
184
|
+
logger.info(
|
|
185
|
+
f"MCP discovered {len(tools)} tool(s) across "
|
|
186
|
+
f"{len({t.server for t in tools})} server(s)."
|
|
187
|
+
)
|
|
188
|
+
return tools
|
|
189
|
+
|
|
190
|
+
def call_tool(
|
|
191
|
+
self, server: str, name: str, arguments: Optional[Dict[str, Any]] = None
|
|
192
|
+
) -> Optional[str]:
|
|
193
|
+
"""Invoke ``name`` on ``server`` with ``arguments``; return its text.
|
|
194
|
+
|
|
195
|
+
Returns the tool's concatenated text content on success, or ``None`` on
|
|
196
|
+
any failure (unknown server, missing SDK, server error, tool error, or
|
|
197
|
+
timeout). Every call is logged. Bounded by ``call_timeout`` — a hanging
|
|
198
|
+
tool can never block a build.
|
|
199
|
+
|
|
200
|
+
SEAM: this is the single chokepoint a future agentic tool-calling loop
|
|
201
|
+
would drive. That loop is out of scope here; this method gives it a
|
|
202
|
+
clean, safe, audited entry point (config-gated, timeout-bounded,
|
|
203
|
+
never-raises) to build on without touching the build loop's internals.
|
|
204
|
+
"""
|
|
205
|
+
cfg = next((s for s in self.servers if s["name"] == server), None)
|
|
206
|
+
if cfg is None:
|
|
207
|
+
logger.warning(f"MCP call_tool: no configured server named '{server}'.")
|
|
208
|
+
return None
|
|
209
|
+
if not self._allowed(server, name):
|
|
210
|
+
logger.warning(
|
|
211
|
+
f"MCP call_tool: {server}.{name} not in allow_tools; refused."
|
|
212
|
+
)
|
|
213
|
+
return None
|
|
214
|
+
logger.info(
|
|
215
|
+
f"MCP call_tool: {server}.{name} args={list((arguments or {}).keys())}"
|
|
216
|
+
)
|
|
217
|
+
return run_bounded(
|
|
218
|
+
lambda: _call_tool(cfg, name, arguments or {}),
|
|
219
|
+
self.call_timeout,
|
|
220
|
+
default=None,
|
|
221
|
+
what=f"MCP tool call ({server}.{name})",
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
def describe_tools(self, cap: int = 25) -> str:
|
|
225
|
+
"""Render the registry as a concise text block for prompt awareness.
|
|
226
|
+
|
|
227
|
+
Empty string when there are no tools (so callers can append
|
|
228
|
+
unconditionally). Bounded by ``cap`` so a server exposing hundreds of
|
|
229
|
+
tools can't blow the prompt budget; each line is one tool.
|
|
230
|
+
"""
|
|
231
|
+
tools = self.tools
|
|
232
|
+
if not tools:
|
|
233
|
+
return ""
|
|
234
|
+
lines: List[str] = []
|
|
235
|
+
for t in tools[:cap]:
|
|
236
|
+
desc = " ".join((t.description or "").split()) # collapse whitespace
|
|
237
|
+
if len(desc) > 200:
|
|
238
|
+
desc = desc[:197] + "..."
|
|
239
|
+
suffix = f": {desc}" if desc else ""
|
|
240
|
+
lines.append(f"- {t.qualified_name}{suffix}")
|
|
241
|
+
if len(tools) > cap:
|
|
242
|
+
lines.append(f"- ... and {len(tools) - cap} more")
|
|
243
|
+
return "\n".join(lines)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# ---------------------------------------------------------------------------
|
|
247
|
+
# Worker-thread bodies. Imported lazily so `mcp` stays an optional dependency,
|
|
248
|
+
# and each runs its own asyncio event loop in the daemon thread (the orchestrator
|
|
249
|
+
# is synchronous; no loop is shared or held across the call).
|
|
250
|
+
# ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _server_params(server: Dict[str, Any]):
|
|
254
|
+
from mcp import StdioServerParameters
|
|
255
|
+
|
|
256
|
+
args = server.get("args") or []
|
|
257
|
+
if not isinstance(args, list):
|
|
258
|
+
args = [str(args)]
|
|
259
|
+
env = server.get("env")
|
|
260
|
+
return StdioServerParameters(
|
|
261
|
+
command=str(server["command"]),
|
|
262
|
+
args=[str(a) for a in args],
|
|
263
|
+
env=dict(env) if isinstance(env, dict) else None,
|
|
264
|
+
cwd=server.get("cwd"),
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _auth_headers(server: Dict[str, Any]) -> Optional[Dict[str, str]]:
|
|
269
|
+
"""Auth headers for a remote server: explicit ``headers`` plus a Bearer token
|
|
270
|
+
read from ``api_key_env`` (so the token stays in the environment, never in
|
|
271
|
+
config on disk). Returns None when there is nothing to send."""
|
|
272
|
+
import os
|
|
273
|
+
|
|
274
|
+
headers = {str(k): str(v) for k, v in (server.get("headers") or {}).items()}
|
|
275
|
+
env_var = server.get("api_key_env")
|
|
276
|
+
if env_var:
|
|
277
|
+
token = os.environ.get(str(env_var))
|
|
278
|
+
if token:
|
|
279
|
+
headers.setdefault("Authorization", f"Bearer {token}")
|
|
280
|
+
else:
|
|
281
|
+
logger.warning(
|
|
282
|
+
f"MCP server '{server.get('name')}': api_key_env {env_var!r} is unset."
|
|
283
|
+
)
|
|
284
|
+
return headers or None
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@asynccontextmanager
|
|
288
|
+
async def _open_session(server: Dict[str, Any]):
|
|
289
|
+
"""Yield an initialized MCP ``ClientSession`` over the server's transport.
|
|
290
|
+
|
|
291
|
+
stdio launches a subprocess; ``http``/``streamable-http`` and ``sse`` connect
|
|
292
|
+
to a remote endpoint (``url``) with optional auth headers — this is what lets
|
|
293
|
+
misterdev reach a hosted MCP gateway (e.g. Glama) that fronts many servers.
|
|
294
|
+
The ``ClientSession`` handling is identical across transports.
|
|
295
|
+
"""
|
|
296
|
+
from mcp import ClientSession
|
|
297
|
+
|
|
298
|
+
transport = (server.get("transport") or "stdio").lower()
|
|
299
|
+
if transport in ("http", "streamable-http", "streamable_http"):
|
|
300
|
+
from mcp.client.streamable_http import streamablehttp_client
|
|
301
|
+
|
|
302
|
+
async with streamablehttp_client(
|
|
303
|
+
server["url"], headers=_auth_headers(server)
|
|
304
|
+
) as (read, write, _get_session_id):
|
|
305
|
+
async with ClientSession(read, write) as session:
|
|
306
|
+
await session.initialize()
|
|
307
|
+
yield session
|
|
308
|
+
elif transport == "sse":
|
|
309
|
+
from mcp.client.sse import sse_client
|
|
310
|
+
|
|
311
|
+
async with sse_client(server["url"], headers=_auth_headers(server)) as (
|
|
312
|
+
read,
|
|
313
|
+
write,
|
|
314
|
+
):
|
|
315
|
+
async with ClientSession(read, write) as session:
|
|
316
|
+
await session.initialize()
|
|
317
|
+
yield session
|
|
318
|
+
else:
|
|
319
|
+
from mcp.client.stdio import stdio_client
|
|
320
|
+
|
|
321
|
+
async with stdio_client(_server_params(server)) as (read, write):
|
|
322
|
+
async with ClientSession(read, write) as session:
|
|
323
|
+
await session.initialize()
|
|
324
|
+
yield session
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _list_tools(server: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
328
|
+
import asyncio
|
|
329
|
+
|
|
330
|
+
async def _main() -> List[Dict[str, Any]]:
|
|
331
|
+
async with _open_session(server) as session:
|
|
332
|
+
result = await session.list_tools()
|
|
333
|
+
return [
|
|
334
|
+
{
|
|
335
|
+
"name": tool.name,
|
|
336
|
+
"description": tool.description or "",
|
|
337
|
+
"input_schema": getattr(tool, "inputSchema", None) or {},
|
|
338
|
+
}
|
|
339
|
+
for tool in result.tools
|
|
340
|
+
]
|
|
341
|
+
|
|
342
|
+
return asyncio.run(_main())
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _call_tool(
|
|
346
|
+
server: Dict[str, Any], name: str, arguments: Dict[str, Any]
|
|
347
|
+
) -> Optional[str]:
|
|
348
|
+
import asyncio
|
|
349
|
+
|
|
350
|
+
async def _main() -> Optional[str]:
|
|
351
|
+
async with _open_session(server) as session:
|
|
352
|
+
result = await session.call_tool(name, arguments=arguments)
|
|
353
|
+
if getattr(result, "isError", False):
|
|
354
|
+
logger.debug(f"MCP tool {name} returned an error result.")
|
|
355
|
+
return None
|
|
356
|
+
return _result_text(result)
|
|
357
|
+
|
|
358
|
+
return asyncio.run(_main())
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _result_text(result) -> Optional[str]:
|
|
362
|
+
"""Concatenate the text content of a CallToolResult, or None if none."""
|
|
363
|
+
parts: List[str] = []
|
|
364
|
+
for block in getattr(result, "content", None) or []:
|
|
365
|
+
text = getattr(block, "text", None)
|
|
366
|
+
if text:
|
|
367
|
+
parts.append(text)
|
|
368
|
+
return "\n".join(parts) if parts else None
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Bounded, opt-in agentic tool-gathering loop over the MCP substrate.
|
|
2
|
+
|
|
3
|
+
When ``orchestrator.mcp_tool_use`` is on and an :class:`~misterdev.core.integration.mcp.MCPManager`
|
|
4
|
+
with discovered tools exists, this runs a *pre-edit* loop in which the model may
|
|
5
|
+
request MCP tool calls to gather information before the normal edit-generation
|
|
6
|
+
path runs. It is purely ADDITIVE: it produces a context string the executor
|
|
7
|
+
prepends to the task context, then proceeds unchanged. When the flag is off the
|
|
8
|
+
executor never calls this, so the build is byte-identical to today.
|
|
9
|
+
|
|
10
|
+
Discipline mirrors :mod:`misterdev.core.context.lsp` and
|
|
11
|
+
:mod:`misterdev.core.integration.mcp`: every tool call is bounded (it goes
|
|
12
|
+
through :meth:`MCPManager.call_tool`, which is timeout-guarded and never raises),
|
|
13
|
+
the loop is hard-capped by ``max_rounds``, each call is audited (logged), and any
|
|
14
|
+
failure (no manager, no tools, model error, unparseable request, tool error)
|
|
15
|
+
degrades to "gather nothing" rather than raising into the build.
|
|
16
|
+
|
|
17
|
+
Protocol (deterministic, tolerant of a model that wants no tool): each round the
|
|
18
|
+
model is shown the available tools and asked to reply with EITHER a single line
|
|
19
|
+
|
|
20
|
+
CALL <server>.<tool> {"arg": value, ...}
|
|
21
|
+
|
|
22
|
+
to request one tool call, OR the literal token ``NO_TOOL`` (or anything that does
|
|
23
|
+
not parse as a CALL line) to stop. We parse the first valid CALL line found; if
|
|
24
|
+
none is found the loop stops. JSON args are optional (a missing/empty/invalid
|
|
25
|
+
args object is treated as ``{}``, logged). The tool result is appended to the
|
|
26
|
+
running gathered-context block and fed back into the next round's prompt.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import re
|
|
31
|
+
from typing import Callable, Dict, List, Optional, Tuple
|
|
32
|
+
|
|
33
|
+
from misterdev.core.integration.mcp import MCPManager
|
|
34
|
+
from misterdev.llm.responses import (
|
|
35
|
+
extract_balanced_span as _extract_balanced_object,
|
|
36
|
+
)
|
|
37
|
+
from misterdev.logging_setup import setup_logger
|
|
38
|
+
|
|
39
|
+
logger = setup_logger(__name__)
|
|
40
|
+
|
|
41
|
+
# "CALL server.tool" anywhere in the reply. Tolerant on purpose: searched (not
|
|
42
|
+
# anchored) so leading markdown (backticks, ``**``, ``> ``) and trailing prose
|
|
43
|
+
# don't break it, and case-insensitive with word boundaries so neither "recall"
|
|
44
|
+
# nor a lowercased keyword trips it. The server keeps dots (``a.b.tool`` ->
|
|
45
|
+
# server ``a.b``, tool ``tool``); the args object is located and balanced
|
|
46
|
+
# separately so it may span multiple lines.
|
|
47
|
+
_CALL_RE = re.compile(
|
|
48
|
+
r"\bCALL\b\s+([A-Za-z0-9_.\-]+)\.([A-Za-z0-9_\-]+)",
|
|
49
|
+
re.IGNORECASE,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
_GATHER_HEADER = "## Information gathered via tools\n"
|
|
53
|
+
|
|
54
|
+
_INSTRUCTION = (
|
|
55
|
+
"You may gather information using the tools below BEFORE editing. "
|
|
56
|
+
"To call a tool, reply with EXACTLY one line:\n"
|
|
57
|
+
' CALL <server>.<tool> {{"arg": value}}\n'
|
|
58
|
+
"(the JSON arguments object is optional). To gather nothing further and "
|
|
59
|
+
"proceed to editing, reply with NO_TOOL. Request at most one tool per reply.\n\n"
|
|
60
|
+
"## Available tools\n{tools}\n"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _parse_call(text: str) -> Optional[Tuple[str, str, dict]]:
|
|
65
|
+
"""Parse the first ``CALL server.tool {json}`` line; ``None`` if absent.
|
|
66
|
+
|
|
67
|
+
A present-but-malformed JSON args object degrades to ``{}`` (logged), so the
|
|
68
|
+
tool is still attempted with no arguments rather than the round being lost.
|
|
69
|
+
"""
|
|
70
|
+
if not text:
|
|
71
|
+
return None
|
|
72
|
+
m = _CALL_RE.search(text)
|
|
73
|
+
if m is None:
|
|
74
|
+
return None
|
|
75
|
+
server, tool = m.group(1), m.group(2)
|
|
76
|
+
args: dict = {}
|
|
77
|
+
brace = text.find("{", m.end())
|
|
78
|
+
# Only treat a following object as the args if nothing but whitespace
|
|
79
|
+
# separates it from the call, so a stray ``{`` in later prose isn't grabbed.
|
|
80
|
+
if brace != -1 and text[m.end() : brace].strip() == "":
|
|
81
|
+
raw_args = _extract_balanced_object(text, brace)
|
|
82
|
+
if raw_args:
|
|
83
|
+
try:
|
|
84
|
+
parsed = json.loads(raw_args)
|
|
85
|
+
if isinstance(parsed, dict):
|
|
86
|
+
args = parsed
|
|
87
|
+
else:
|
|
88
|
+
logger.debug("MCP gather: CALL args not a JSON object; using {}.")
|
|
89
|
+
except (json.JSONDecodeError, ValueError):
|
|
90
|
+
logger.debug("MCP gather: unparseable CALL args; using {}.")
|
|
91
|
+
return server, tool, args
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def gather_context(
|
|
95
|
+
manager: Optional[MCPManager],
|
|
96
|
+
ask: Callable[[str], Optional[str]],
|
|
97
|
+
*,
|
|
98
|
+
task_description: str = "",
|
|
99
|
+
max_rounds: int = 3,
|
|
100
|
+
tools_cap: int = 25,
|
|
101
|
+
local_tools: Optional[
|
|
102
|
+
Dict[str, Tuple[str, Callable[[dict], Optional[str]]]]
|
|
103
|
+
] = None,
|
|
104
|
+
) -> str:
|
|
105
|
+
"""Run the bounded tool-gathering loop; return the gathered-context block.
|
|
106
|
+
|
|
107
|
+
``manager`` is the MCP manager (may be ``None``). ``local_tools`` maps a
|
|
108
|
+
registered gather-safe tool's name to ``(description, call)`` and is exposed
|
|
109
|
+
to the model as ``local.<name>``, so plugin tools and MCP tools are called
|
|
110
|
+
through the one loop. ``ask`` takes a prompt and returns the model's reply
|
|
111
|
+
(``None``/empty stops). ``max_rounds`` hard-caps the iterations; < 1 disables.
|
|
112
|
+
Returns a context string (empty when nothing was gathered), suitable for
|
|
113
|
+
prepending to the task context. Never raises: any failure is logged and
|
|
114
|
+
degrades to whatever was gathered so far.
|
|
115
|
+
"""
|
|
116
|
+
local_tools = local_tools or {}
|
|
117
|
+
if (manager is None and not local_tools) or max_rounds < 1:
|
|
118
|
+
return ""
|
|
119
|
+
tools = ""
|
|
120
|
+
if manager is not None:
|
|
121
|
+
try:
|
|
122
|
+
tools = manager.describe_tools(cap=tools_cap) or ""
|
|
123
|
+
except Exception as e: # discovery problem; degrade to local tools only
|
|
124
|
+
logger.debug(f"gather: MCP tool discovery failed: {e}")
|
|
125
|
+
tools = ""
|
|
126
|
+
if local_tools:
|
|
127
|
+
local_desc = "\n".join(
|
|
128
|
+
f"- local.{name}: {desc}" for name, (desc, _) in local_tools.items()
|
|
129
|
+
)
|
|
130
|
+
tools = f"{tools}\n{local_desc}" if tools else local_desc
|
|
131
|
+
if not tools:
|
|
132
|
+
return ""
|
|
133
|
+
|
|
134
|
+
gathered: List[str] = []
|
|
135
|
+
for round_idx in range(max_rounds):
|
|
136
|
+
prompt = _INSTRUCTION.format(tools=tools)
|
|
137
|
+
if task_description:
|
|
138
|
+
prompt += (
|
|
139
|
+
f"\n## Task you are gathering information for\n{task_description}\n"
|
|
140
|
+
)
|
|
141
|
+
if gathered:
|
|
142
|
+
prompt += "\n" + _GATHER_HEADER + "\n\n".join(gathered) + "\n"
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
reply = ask(prompt)
|
|
146
|
+
except Exception as e: # a model/client failure must not sink the build
|
|
147
|
+
logger.debug(f"MCP gather: model call failed in round {round_idx + 1}: {e}")
|
|
148
|
+
break
|
|
149
|
+
|
|
150
|
+
parsed = _parse_call(reply or "")
|
|
151
|
+
if parsed is None:
|
|
152
|
+
logger.info(
|
|
153
|
+
f"MCP gather: model requested no tool in round {round_idx + 1}; "
|
|
154
|
+
"stopping."
|
|
155
|
+
)
|
|
156
|
+
break
|
|
157
|
+
|
|
158
|
+
server, tool, args = parsed
|
|
159
|
+
logger.info(
|
|
160
|
+
f"gather round {round_idx + 1}/{max_rounds}: CALL {server}.{tool} "
|
|
161
|
+
f"args={sorted(args.keys())}"
|
|
162
|
+
)
|
|
163
|
+
if server == "local" and tool in local_tools:
|
|
164
|
+
try:
|
|
165
|
+
result = local_tools[tool][1](args)
|
|
166
|
+
except Exception as e: # a plugin tool must not sink the build
|
|
167
|
+
logger.debug(f"gather: local tool {tool!r} error: {e}")
|
|
168
|
+
result = None
|
|
169
|
+
elif manager is not None:
|
|
170
|
+
result = manager.call_tool(server, tool, args)
|
|
171
|
+
else:
|
|
172
|
+
result = None
|
|
173
|
+
if result is None:
|
|
174
|
+
# Tool error / unknown server / timeout already logged by call_tool.
|
|
175
|
+
gathered.append(f"### {server}.{tool} -> (no result / error)")
|
|
176
|
+
continue
|
|
177
|
+
gathered.append(f"### {server}.{tool}\n{result}")
|
|
178
|
+
|
|
179
|
+
if not gathered:
|
|
180
|
+
return ""
|
|
181
|
+
return (
|
|
182
|
+
"\n\n"
|
|
183
|
+
+ _GATHER_HEADER
|
|
184
|
+
+ "These results were gathered via tools to inform the edit below.\n\n"
|
|
185
|
+
+ "\n\n".join(gathered)
|
|
186
|
+
)
|
misterdev/core/models.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field
|
|
2
|
+
from typing import Dict, Any, List, Optional
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ExecutionResult(BaseModel):
|
|
7
|
+
status: str # 'completed', 'failed', 'in_progress'
|
|
8
|
+
message: str
|
|
9
|
+
logs: str = ""
|
|
10
|
+
start_time: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
11
|
+
end_time: Optional[datetime] = None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Task(BaseModel):
|
|
15
|
+
id: str
|
|
16
|
+
description: str
|
|
17
|
+
type: str = "default"
|
|
18
|
+
status: str = "pending"
|
|
19
|
+
source_ref: Optional[str] = None # e.g., file path
|
|
20
|
+
project_ref: str
|
|
21
|
+
processor_data: Dict[str, Any] = Field(default_factory=dict)
|
|
22
|
+
execution_history: List[ExecutionResult] = Field(default_factory=list)
|
|
23
|
+
# Fields ported from /build skill task decomposition
|
|
24
|
+
title: str = ""
|
|
25
|
+
acceptance_criteria: str = ""
|
|
26
|
+
files_to_create: List[str] = Field(default_factory=list)
|
|
27
|
+
files_to_modify: List[str] = Field(default_factory=list)
|
|
28
|
+
dependencies: List[str] = Field(
|
|
29
|
+
default_factory=list
|
|
30
|
+
) # task IDs that must complete first
|
|
31
|
+
complexity: str = "medium" # trivial, small, medium, large, architectural
|
|
32
|
+
category: str = "feature" # infrastructure, core, feature, fix, test, docs, integration, cleanup
|
|
33
|
+
context_files: List[str] = Field(
|
|
34
|
+
default_factory=list
|
|
35
|
+
) # Files relevant for context but not modified
|