canopy-cli 3.1.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.
- canopy/__init__.py +2 -0
- canopy/actions/__init__.py +32 -0
- canopy/actions/aliases.py +421 -0
- canopy/actions/augments.py +55 -0
- canopy/actions/bootstrap.py +249 -0
- canopy/actions/bot_resolutions.py +123 -0
- canopy/actions/bot_status.py +133 -0
- canopy/actions/commit.py +511 -0
- canopy/actions/conflicts.py +314 -0
- canopy/actions/doctor.py +1459 -0
- canopy/actions/draft_replies.py +185 -0
- canopy/actions/drift.py +241 -0
- canopy/actions/errors.py +115 -0
- canopy/actions/evacuate.py +192 -0
- canopy/actions/feature_state.py +607 -0
- canopy/actions/historian.py +612 -0
- canopy/actions/ide_workspace.py +49 -0
- canopy/actions/last_visit.py +83 -0
- canopy/actions/migrate_slots.py +313 -0
- canopy/actions/preflight_state.py +97 -0
- canopy/actions/push.py +199 -0
- canopy/actions/reads.py +304 -0
- canopy/actions/resume.py +582 -0
- canopy/actions/review_filter.py +135 -0
- canopy/actions/ship.py +399 -0
- canopy/actions/slot_details.py +208 -0
- canopy/actions/slot_load.py +383 -0
- canopy/actions/slots.py +221 -0
- canopy/actions/stash.py +230 -0
- canopy/actions/switch.py +775 -0
- canopy/actions/switch_preflight.py +192 -0
- canopy/actions/thread_actions.py +88 -0
- canopy/actions/thread_resolutions.py +101 -0
- canopy/actions/triage.py +286 -0
- canopy/agent/__init__.py +5 -0
- canopy/agent/runner.py +129 -0
- canopy/agent_setup/__init__.py +264 -0
- canopy/agent_setup/skills/augment-canopy/SKILL.md +116 -0
- canopy/agent_setup/skills/using-canopy/SKILL.md +191 -0
- canopy/cli/__init__.py +0 -0
- canopy/cli/main.py +4152 -0
- canopy/cli/render.py +98 -0
- canopy/cli/ui.py +150 -0
- canopy/features/__init__.py +2 -0
- canopy/features/coordinator.py +1256 -0
- canopy/git/__init__.py +0 -0
- canopy/git/hooks.py +173 -0
- canopy/git/multi.py +435 -0
- canopy/git/repo.py +859 -0
- canopy/git/templates/post-checkout.py +67 -0
- canopy/graph/__init__.py +0 -0
- canopy/integrations/__init__.py +0 -0
- canopy/integrations/github.py +983 -0
- canopy/integrations/linear.py +307 -0
- canopy/integrations/precommit.py +239 -0
- canopy/mcp/__init__.py +0 -0
- canopy/mcp/client.py +329 -0
- canopy/mcp/server.py +1797 -0
- canopy/providers/__init__.py +105 -0
- canopy/providers/github_issues.py +289 -0
- canopy/providers/linear.py +341 -0
- canopy/providers/types.py +149 -0
- canopy/workspace/__init__.py +4 -0
- canopy/workspace/config.py +378 -0
- canopy/workspace/context.py +224 -0
- canopy/workspace/discovery.py +197 -0
- canopy/workspace/workspace.py +173 -0
- canopy_cli-3.1.0.dist-info/METADATA +282 -0
- canopy_cli-3.1.0.dist-info/RECORD +71 -0
- canopy_cli-3.1.0.dist-info/WHEEL +4 -0
- canopy_cli-3.1.0.dist-info/entry_points.txt +3 -0
canopy/mcp/client.py
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Canopy MCP Client — call tools on external MCP servers.
|
|
3
|
+
|
|
4
|
+
Two transports supported:
|
|
5
|
+
|
|
6
|
+
1. **stdio** (subprocess) — for local npm/python MCP servers::
|
|
7
|
+
|
|
8
|
+
{
|
|
9
|
+
"github": {
|
|
10
|
+
"command": "npx",
|
|
11
|
+
"args": ["-y", "@modelcontextprotocol/server-github"],
|
|
12
|
+
"env": {"GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_..."}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
2. **HTTP (streamable)** — for hosted MCP servers like Linear's. Set
|
|
17
|
+
``"type": "http"`` (or just include ``"url"``); pass headers for
|
|
18
|
+
token-based auth, or ``"oauth": true`` for browser-based OAuth flow
|
|
19
|
+
with token caching::
|
|
20
|
+
|
|
21
|
+
{
|
|
22
|
+
"linear": {
|
|
23
|
+
"type": "http",
|
|
24
|
+
"url": "https://mcp.linear.app/mcp",
|
|
25
|
+
"oauth": true
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
Configs live in ``.canopy/mcps.json`` (canopy-specific) or ``.mcp.json``
|
|
30
|
+
(shared with Claude Code et al.); canopy merges with .canopy taking
|
|
31
|
+
precedence.
|
|
32
|
+
"""
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import asyncio
|
|
36
|
+
import json
|
|
37
|
+
import os
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from typing import Any
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class McpClientError(Exception):
|
|
43
|
+
"""An MCP client operation failed."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _is_http_config(server_config: dict) -> bool:
|
|
47
|
+
"""True if the config describes an HTTP-transport MCP server."""
|
|
48
|
+
return (
|
|
49
|
+
server_config.get("type") in {"http", "streamable-http", "sse"}
|
|
50
|
+
or bool(server_config.get("url"))
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _load_mcp_configs(workspace_root: Path) -> dict[str, dict]:
|
|
55
|
+
"""Load MCP server configs.
|
|
56
|
+
|
|
57
|
+
Reads two sources, merging with .canopy/mcps.json taking precedence:
|
|
58
|
+
|
|
59
|
+
1. ``.mcp.json`` at workspace root — the Claude Code / portable
|
|
60
|
+
convention. Entries live under a top-level ``mcpServers`` key.
|
|
61
|
+
2. ``.canopy/mcps.json`` — canopy's own flat format. Overrides
|
|
62
|
+
anything in .mcp.json on key collision so users can customize
|
|
63
|
+
per-server configs without editing the shared file.
|
|
64
|
+
"""
|
|
65
|
+
configs: dict[str, dict] = {}
|
|
66
|
+
|
|
67
|
+
shared_path = workspace_root / ".mcp.json"
|
|
68
|
+
if shared_path.exists():
|
|
69
|
+
try:
|
|
70
|
+
shared = json.loads(shared_path.read_text())
|
|
71
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
72
|
+
raise McpClientError(f"Failed to read {shared_path}: {e}")
|
|
73
|
+
servers = shared.get("mcpServers") if isinstance(shared, dict) else None
|
|
74
|
+
if isinstance(servers, dict):
|
|
75
|
+
for name, cfg in servers.items():
|
|
76
|
+
if isinstance(cfg, dict):
|
|
77
|
+
configs[name] = cfg
|
|
78
|
+
|
|
79
|
+
canopy_path = workspace_root / ".canopy" / "mcps.json"
|
|
80
|
+
if canopy_path.exists():
|
|
81
|
+
try:
|
|
82
|
+
canopy_cfg = json.loads(canopy_path.read_text())
|
|
83
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
84
|
+
raise McpClientError(f"Failed to read {canopy_path}: {e}")
|
|
85
|
+
if isinstance(canopy_cfg, dict):
|
|
86
|
+
for name, cfg in canopy_cfg.items():
|
|
87
|
+
if isinstance(cfg, dict):
|
|
88
|
+
configs[name] = cfg
|
|
89
|
+
|
|
90
|
+
return configs
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def get_mcp_config(workspace_root: Path, server_name: str) -> dict | None:
|
|
94
|
+
"""Get config for a named MCP server, or None if not configured."""
|
|
95
|
+
configs = _load_mcp_configs(workspace_root)
|
|
96
|
+
return configs.get(server_name)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def is_mcp_configured(workspace_root: Path, server_name: str) -> bool:
|
|
100
|
+
"""Check if a named MCP server is configured."""
|
|
101
|
+
return get_mcp_config(workspace_root, server_name) is not None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def _call_tool_async(
|
|
105
|
+
server_config: dict,
|
|
106
|
+
tool_name: str,
|
|
107
|
+
arguments: dict[str, Any] | None = None,
|
|
108
|
+
timeout: float = 30.0,
|
|
109
|
+
server_name: str | None = None,
|
|
110
|
+
) -> Any:
|
|
111
|
+
"""Connect to an MCP server, call a tool, and return the result.
|
|
112
|
+
|
|
113
|
+
Dispatches to stdio or HTTP transport based on the config shape.
|
|
114
|
+
"""
|
|
115
|
+
if _is_http_config(server_config):
|
|
116
|
+
return await _http_call(
|
|
117
|
+
server_config, "call_tool", tool_name=tool_name,
|
|
118
|
+
arguments=arguments or {}, server_name=server_name,
|
|
119
|
+
)
|
|
120
|
+
return await _stdio_call(
|
|
121
|
+
server_config, "call_tool", tool_name=tool_name,
|
|
122
|
+
arguments=arguments or {},
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
async def _stdio_call(server_config: dict, op: str, **kwargs) -> Any:
|
|
127
|
+
"""Spawn a stdio MCP server and run one operation."""
|
|
128
|
+
from mcp import ClientSession, StdioServerParameters
|
|
129
|
+
from mcp.client.stdio import stdio_client
|
|
130
|
+
|
|
131
|
+
env = dict(os.environ)
|
|
132
|
+
if server_config.get("env"):
|
|
133
|
+
env.update(server_config["env"])
|
|
134
|
+
|
|
135
|
+
server_params = StdioServerParameters(
|
|
136
|
+
command=server_config["command"],
|
|
137
|
+
args=server_config.get("args", []),
|
|
138
|
+
env=env,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
async with stdio_client(server_params) as (read, write):
|
|
142
|
+
async with ClientSession(read, write) as session:
|
|
143
|
+
await session.initialize()
|
|
144
|
+
return await _run_op(session, op, **kwargs)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
async def _http_call(
|
|
148
|
+
server_config: dict, op: str, *,
|
|
149
|
+
server_name: str | None = None, **kwargs,
|
|
150
|
+
) -> Any:
|
|
151
|
+
"""Connect to an HTTP MCP server (streamable HTTP) and run one operation.
|
|
152
|
+
|
|
153
|
+
Auth handling:
|
|
154
|
+
- ``headers`` (dict) on config — passed through (e.g. ``Authorization: Bearer ...``)
|
|
155
|
+
- ``oauth: true`` on config — uses OAuthClientProvider with token cache at
|
|
156
|
+
``~/.canopy/mcp-tokens/<server_name>.json``. First call opens a browser
|
|
157
|
+
for the OAuth flow; cached token is reused after.
|
|
158
|
+
"""
|
|
159
|
+
from mcp import ClientSession
|
|
160
|
+
from mcp.client.streamable_http import streamablehttp_client
|
|
161
|
+
|
|
162
|
+
url = server_config["url"]
|
|
163
|
+
headers = dict(server_config.get("headers") or {})
|
|
164
|
+
auth = None
|
|
165
|
+
if server_config.get("oauth"):
|
|
166
|
+
auth = _make_oauth_provider(server_name or "default", url)
|
|
167
|
+
|
|
168
|
+
async with streamablehttp_client(url, headers=headers or None, auth=auth) as (read, write, _):
|
|
169
|
+
async with ClientSession(read, write) as session:
|
|
170
|
+
await session.initialize()
|
|
171
|
+
return await _run_op(session, op, **kwargs)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
async def _run_op(session, op: str, **kwargs) -> Any:
|
|
175
|
+
if op == "call_tool":
|
|
176
|
+
return await session.call_tool(kwargs["tool_name"], kwargs.get("arguments") or {})
|
|
177
|
+
if op == "list_tools":
|
|
178
|
+
return await session.list_tools()
|
|
179
|
+
raise McpClientError(f"unknown MCP op: {op}")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _make_oauth_provider(server_name: str, server_url: str):
|
|
183
|
+
"""Build an OAuthClientProvider with on-disk token caching.
|
|
184
|
+
|
|
185
|
+
Imported lazily so canopy doesn't require the auth dependency tree
|
|
186
|
+
when only stdio servers are used.
|
|
187
|
+
"""
|
|
188
|
+
from mcp.client.auth import OAuthClientProvider, TokenStorage
|
|
189
|
+
from mcp.shared.auth import OAuthClientMetadata, OAuthClientInformationFull, OAuthToken
|
|
190
|
+
|
|
191
|
+
cache_dir = Path.home() / ".canopy" / "mcp-tokens"
|
|
192
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
193
|
+
token_path = cache_dir / f"{server_name}.tokens.json"
|
|
194
|
+
client_info_path = cache_dir / f"{server_name}.client.json"
|
|
195
|
+
|
|
196
|
+
class FileTokenStorage(TokenStorage):
|
|
197
|
+
async def get_tokens(self) -> OAuthToken | None:
|
|
198
|
+
if not token_path.exists():
|
|
199
|
+
return None
|
|
200
|
+
data = json.loads(token_path.read_text())
|
|
201
|
+
return OAuthToken(**data)
|
|
202
|
+
|
|
203
|
+
async def set_tokens(self, tokens: OAuthToken) -> None:
|
|
204
|
+
token_path.write_text(tokens.model_dump_json(indent=2))
|
|
205
|
+
|
|
206
|
+
async def get_client_info(self) -> OAuthClientInformationFull | None:
|
|
207
|
+
if not client_info_path.exists():
|
|
208
|
+
return None
|
|
209
|
+
return OAuthClientInformationFull(**json.loads(client_info_path.read_text()))
|
|
210
|
+
|
|
211
|
+
async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
|
|
212
|
+
client_info_path.write_text(client_info.model_dump_json(indent=2))
|
|
213
|
+
|
|
214
|
+
metadata = OAuthClientMetadata(
|
|
215
|
+
client_name="canopy",
|
|
216
|
+
redirect_uris=["http://localhost:33418/callback"],
|
|
217
|
+
grant_types=["authorization_code", "refresh_token"],
|
|
218
|
+
response_types=["code"],
|
|
219
|
+
token_endpoint_auth_method="none",
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
async def redirect_handler(authorization_url: str) -> None:
|
|
223
|
+
"""Open the user's browser to the OAuth authorization URL."""
|
|
224
|
+
import webbrowser
|
|
225
|
+
print(f"\n → Opening browser for {server_name} OAuth: {authorization_url}\n")
|
|
226
|
+
webbrowser.open(authorization_url)
|
|
227
|
+
|
|
228
|
+
async def callback_handler() -> tuple[str, str | None]:
|
|
229
|
+
"""Run a one-shot HTTP server on localhost:33418 to catch the OAuth redirect."""
|
|
230
|
+
import http.server
|
|
231
|
+
import socketserver
|
|
232
|
+
import urllib.parse
|
|
233
|
+
|
|
234
|
+
captured: dict[str, str] = {}
|
|
235
|
+
|
|
236
|
+
class CallbackHandler(http.server.BaseHTTPRequestHandler):
|
|
237
|
+
def do_GET(self):
|
|
238
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
239
|
+
params = urllib.parse.parse_qs(parsed.query)
|
|
240
|
+
captured["code"] = (params.get("code") or [""])[0]
|
|
241
|
+
captured["state"] = (params.get("state") or [""])[0]
|
|
242
|
+
self.send_response(200)
|
|
243
|
+
self.send_header("Content-Type", "text/html")
|
|
244
|
+
self.end_headers()
|
|
245
|
+
self.wfile.write(
|
|
246
|
+
b"<html><body style='font-family:sans-serif;padding:2em'>"
|
|
247
|
+
b"<h2>Canopy: authorization received</h2>"
|
|
248
|
+
b"<p>You can close this tab.</p>"
|
|
249
|
+
b"</body></html>"
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
def log_message(self, format, *args):
|
|
253
|
+
pass # silence
|
|
254
|
+
|
|
255
|
+
loop = asyncio.get_event_loop()
|
|
256
|
+
|
|
257
|
+
def _serve():
|
|
258
|
+
with socketserver.TCPServer(("localhost", 33418), CallbackHandler) as httpd:
|
|
259
|
+
httpd.handle_request()
|
|
260
|
+
|
|
261
|
+
await loop.run_in_executor(None, _serve)
|
|
262
|
+
return captured.get("code", ""), captured.get("state") or None
|
|
263
|
+
|
|
264
|
+
return OAuthClientProvider(
|
|
265
|
+
server_url=server_url,
|
|
266
|
+
client_metadata=metadata,
|
|
267
|
+
storage=FileTokenStorage(),
|
|
268
|
+
redirect_handler=redirect_handler,
|
|
269
|
+
callback_handler=callback_handler,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def call_tool(
|
|
274
|
+
server_config: dict,
|
|
275
|
+
tool_name: str,
|
|
276
|
+
arguments: dict[str, Any] | None = None,
|
|
277
|
+
timeout: float = 30.0,
|
|
278
|
+
server_name: str | None = None,
|
|
279
|
+
) -> Any:
|
|
280
|
+
"""Sync wrapper — connect to MCP server, call tool, return result.
|
|
281
|
+
|
|
282
|
+
Dispatches to stdio or HTTP based on config shape (see module docstring).
|
|
283
|
+
``server_name`` is used to scope OAuth token cache when the config is
|
|
284
|
+
HTTP+oauth; pass it from the caller (it's the key in mcps.json).
|
|
285
|
+
"""
|
|
286
|
+
return _run_sync(
|
|
287
|
+
_call_tool_async(server_config, tool_name, arguments, timeout, server_name),
|
|
288
|
+
timeout=timeout,
|
|
289
|
+
what=f"call_tool({tool_name})",
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def list_tools(
|
|
294
|
+
server_config: dict, timeout: float = 15.0, server_name: str | None = None,
|
|
295
|
+
) -> list[dict]:
|
|
296
|
+
"""List available tools on an MCP server (stdio or HTTP)."""
|
|
297
|
+
async def _list():
|
|
298
|
+
if _is_http_config(server_config):
|
|
299
|
+
tools_result = await _http_call(
|
|
300
|
+
server_config, "list_tools", server_name=server_name,
|
|
301
|
+
)
|
|
302
|
+
else:
|
|
303
|
+
tools_result = await _stdio_call(server_config, "list_tools")
|
|
304
|
+
return [
|
|
305
|
+
{"name": t.name, "description": t.description or ""}
|
|
306
|
+
for t in tools_result.tools
|
|
307
|
+
]
|
|
308
|
+
|
|
309
|
+
return _run_sync(_list(), timeout=timeout, what="list_tools")
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _run_sync(coro, *, timeout: float, what: str) -> Any:
|
|
313
|
+
"""Run an async coroutine, handling the case where we're already inside a loop."""
|
|
314
|
+
try:
|
|
315
|
+
loop = asyncio.get_running_loop()
|
|
316
|
+
except RuntimeError:
|
|
317
|
+
loop = None
|
|
318
|
+
|
|
319
|
+
try:
|
|
320
|
+
if loop and loop.is_running():
|
|
321
|
+
# Inside an existing loop (e.g., the canopy MCP server host).
|
|
322
|
+
# Run in a separate thread with a fresh loop.
|
|
323
|
+
import concurrent.futures
|
|
324
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
|
325
|
+
future = pool.submit(asyncio.run, coro)
|
|
326
|
+
return future.result(timeout=timeout)
|
|
327
|
+
return asyncio.run(coro)
|
|
328
|
+
except Exception as e:
|
|
329
|
+
raise McpClientError(f"MCP {what} failed: {e}") from e
|