agnoctl 0.1.0a1__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.
- agnoctl/__init__.py +8 -0
- agnoctl/clients/__init__.py +36 -0
- agnoctl/clients/base.py +167 -0
- agnoctl/clients/claude_code.py +166 -0
- agnoctl/clients/claude_desktop.py +141 -0
- agnoctl/clients/codex.py +166 -0
- agnoctl/clients/cursor.py +72 -0
- agnoctl/commands/__init__.py +0 -0
- agnoctl/commands/_common.py +134 -0
- agnoctl/commands/connect.py +530 -0
- agnoctl/commands/create.py +99 -0
- agnoctl/commands/lifecycle.py +142 -0
- agnoctl/commands/status.py +54 -0
- agnoctl/commands/tokens.py +175 -0
- agnoctl/console.py +39 -0
- agnoctl/discovery.py +131 -0
- agnoctl/errors.py +28 -0
- agnoctl/http.py +277 -0
- agnoctl/main.py +107 -0
- agnoctl/mcp_client.py +148 -0
- agnoctl-0.1.0a1.dist-info/METADATA +238 -0
- agnoctl-0.1.0a1.dist-info/RECORD +26 -0
- agnoctl-0.1.0a1.dist-info/WHEEL +5 -0
- agnoctl-0.1.0a1.dist-info/entry_points.txt +3 -0
- agnoctl-0.1.0a1.dist-info/licenses/LICENSE +201 -0
- agnoctl-0.1.0a1.dist-info/top_level.txt +1 -0
agnoctl/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Client adapters: how each coding agent stores MCP server configuration."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Dict, Optional
|
|
5
|
+
|
|
6
|
+
from agnoctl.clients.base import ClientAdapter, ExistingEntry, WriteResult
|
|
7
|
+
from agnoctl.clients.claude_code import ClaudeCodeAdapter
|
|
8
|
+
from agnoctl.clients.claude_desktop import ClaudeDesktopAdapter
|
|
9
|
+
from agnoctl.clients.codex import CodexAdapter
|
|
10
|
+
from agnoctl.clients.cursor import CursorAdapter
|
|
11
|
+
|
|
12
|
+
# Accepted spellings for --clients, mapped to canonical adapter keys. "claude" stays
|
|
13
|
+
# bound to Claude Code (the coding agent); the desktop app is claude-desktop.
|
|
14
|
+
CLIENT_ALIASES = {
|
|
15
|
+
"claude": "claude-code",
|
|
16
|
+
"claude-code": "claude-code",
|
|
17
|
+
"claude-desktop": "claude-desktop",
|
|
18
|
+
"claude-app": "claude-desktop",
|
|
19
|
+
"codex": "codex",
|
|
20
|
+
"cursor": "cursor",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_adapters(
|
|
25
|
+
home: Optional[Path] = None,
|
|
26
|
+
cwd: Optional[Path] = None,
|
|
27
|
+
project: bool = False,
|
|
28
|
+
) -> Dict[str, ClientAdapter]:
|
|
29
|
+
"""All known adapters keyed by canonical client key."""
|
|
30
|
+
claude_scope = "project" if project else "user"
|
|
31
|
+
return {
|
|
32
|
+
"claude-code": ClaudeCodeAdapter(home=home, cwd=cwd, scope=claude_scope),
|
|
33
|
+
"claude-desktop": ClaudeDesktopAdapter(home=home),
|
|
34
|
+
"codex": CodexAdapter(home=home),
|
|
35
|
+
"cursor": CursorAdapter(home=home, cwd=cwd, project=project),
|
|
36
|
+
}
|
agnoctl/clients/base.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Shared shapes for coding-agent client adapters.
|
|
2
|
+
|
|
3
|
+
An adapter knows how one coding agent (Claude Code, Codex, Cursor) stores MCP server
|
|
4
|
+
configuration: how to detect the client on this machine, read an existing entry back
|
|
5
|
+
(for idempotent re-runs), and write an entry pointing at an AgentOS MCP endpoint.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import contextlib
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import stat
|
|
12
|
+
import tempfile
|
|
13
|
+
from abc import ABC, abstractmethod
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Dict, Optional
|
|
17
|
+
|
|
18
|
+
from agnoctl.errors import CLIError
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class ExistingEntry:
|
|
23
|
+
"""An MCP server entry found in a client's configuration."""
|
|
24
|
+
|
|
25
|
+
url: str
|
|
26
|
+
token: Optional[str]
|
|
27
|
+
location: str # human-readable: file path or config scope it was found in
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class WriteResult:
|
|
32
|
+
"""How and where a config write landed."""
|
|
33
|
+
|
|
34
|
+
method: str # "cli" (client's own CLI did the write) | "file" (we edited the config file)
|
|
35
|
+
location: str
|
|
36
|
+
note: Optional[str] = None # caveat worth surfacing to the user (e.g. VCS-shared file)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def servers_table(config: object) -> dict:
|
|
40
|
+
"""The mcpServers mapping from a parsed config, tolerating malformed shapes."""
|
|
41
|
+
if isinstance(config, dict):
|
|
42
|
+
servers = config.get("mcpServers")
|
|
43
|
+
if isinstance(servers, dict):
|
|
44
|
+
return servers
|
|
45
|
+
return {}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def bearer_header(token: str) -> str:
|
|
49
|
+
return "Bearer " + token
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def token_from_authorization(value: Optional[str]) -> Optional[str]:
|
|
53
|
+
"""Extract the raw token from an Authorization header value."""
|
|
54
|
+
if not value:
|
|
55
|
+
return None
|
|
56
|
+
if value.lower().startswith("bearer "):
|
|
57
|
+
return value[len("bearer ") :].strip() or None
|
|
58
|
+
return value.strip() or None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def read_json_lenient(path: Path) -> Optional[Dict[str, Any]]:
|
|
62
|
+
"""For reads: a missing or malformed file simply means no entry found."""
|
|
63
|
+
if not path.exists():
|
|
64
|
+
return None
|
|
65
|
+
try:
|
|
66
|
+
parsed = json.loads(path.read_text())
|
|
67
|
+
except (OSError, json.JSONDecodeError):
|
|
68
|
+
return None
|
|
69
|
+
return parsed if isinstance(parsed, dict) else None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def read_json_strict(path: Path) -> Dict[str, Any]:
|
|
73
|
+
"""For writes: refuse to clobber a file we cannot parse."""
|
|
74
|
+
if not path.exists():
|
|
75
|
+
return {}
|
|
76
|
+
try:
|
|
77
|
+
parsed = json.loads(path.read_text())
|
|
78
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
79
|
+
raise CLIError(
|
|
80
|
+
"Refusing to modify " + str(path) + ": the existing file is not valid JSON (" + str(e) + ").",
|
|
81
|
+
hint="Fix or move the file, then re-run.",
|
|
82
|
+
)
|
|
83
|
+
if not isinstance(parsed, dict):
|
|
84
|
+
raise CLIError("Refusing to modify " + str(path) + ": expected a JSON object at the top level.")
|
|
85
|
+
return parsed
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _plain_file_mode(path: Path) -> int:
|
|
89
|
+
"""The mode a non-secret config write should land with: the target's current mode
|
|
90
|
+
when we are merging into an existing file, otherwise the process default
|
|
91
|
+
(``0o666 & ~umask``). This keeps us from silently tightening or loosening an
|
|
92
|
+
unrelated file that happens to carry no token."""
|
|
93
|
+
try:
|
|
94
|
+
return stat.S_IMODE(path.stat().st_mode)
|
|
95
|
+
except OSError:
|
|
96
|
+
current = os.umask(0)
|
|
97
|
+
os.umask(current)
|
|
98
|
+
return 0o666 & ~current
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def atomic_write_text(path: Path, text: str, *, secure: bool) -> None:
|
|
102
|
+
"""Write ``text`` to ``path`` atomically, never exposing a secret at wide permissions.
|
|
103
|
+
|
|
104
|
+
A crash mid-write must never corrupt the target (these files include Claude Code's
|
|
105
|
+
whole ``~/.claude.json`` user state), and a token must never touch disk at a
|
|
106
|
+
world-readable mode -- even transiently, and even if the process dies between the
|
|
107
|
+
write and a follow-up ``chmod``.
|
|
108
|
+
|
|
109
|
+
So: write to a temp file in the *same directory* (so ``os.replace`` is an atomic
|
|
110
|
+
rename on one filesystem), ``fsync`` it, then replace the target. ``mkstemp`` creates
|
|
111
|
+
the temp file 0600, so a secret is at 0600 from the instant it hits disk; the replace
|
|
112
|
+
carries that mode onto the target. Non-secret writes keep the target's existing (or a
|
|
113
|
+
umask-default) mode.
|
|
114
|
+
"""
|
|
115
|
+
fd, tmp_name = tempfile.mkstemp(dir=str(path.parent), prefix="." + path.name + ".", suffix=".tmp")
|
|
116
|
+
tmp = Path(tmp_name)
|
|
117
|
+
try:
|
|
118
|
+
with os.fdopen(fd, "w") as handle:
|
|
119
|
+
handle.write(text)
|
|
120
|
+
handle.flush()
|
|
121
|
+
os.fsync(handle.fileno())
|
|
122
|
+
# mkstemp already created tmp as 0600; set the final mode explicitly so intent is
|
|
123
|
+
# visible and a non-secret write does not inherit an over-tight 0600 by accident.
|
|
124
|
+
os.chmod(tmp, 0o600 if secure else _plain_file_mode(path))
|
|
125
|
+
os.replace(tmp, path)
|
|
126
|
+
except BaseException:
|
|
127
|
+
with contextlib.suppress(OSError):
|
|
128
|
+
os.unlink(tmp)
|
|
129
|
+
raise
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def write_servers_entry(
|
|
133
|
+
path: Path, server_name: str, entry: Dict[str, Any], *, secure: bool, mkdir: bool = False
|
|
134
|
+
) -> None:
|
|
135
|
+
"""Create or replace one ``mcpServers`` entry in a JSON config file.
|
|
136
|
+
|
|
137
|
+
Shared by the file-editing adapters so the safety behavior -- strict parse before
|
|
138
|
+
write, refusal on a malformed ``mcpServers``, atomic replace, 0600 permissions when
|
|
139
|
+
the entry carries a token -- cannot drift between clients.
|
|
140
|
+
"""
|
|
141
|
+
config = read_json_strict(path)
|
|
142
|
+
servers = config.get("mcpServers")
|
|
143
|
+
if servers is None:
|
|
144
|
+
servers = {}
|
|
145
|
+
config["mcpServers"] = servers
|
|
146
|
+
elif not isinstance(servers, dict):
|
|
147
|
+
raise CLIError("Refusing to modify " + str(path) + ": 'mcpServers' is not an object.")
|
|
148
|
+
servers[server_name] = entry
|
|
149
|
+
if mkdir:
|
|
150
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
151
|
+
atomic_write_text(path, json.dumps(config, indent=2) + "\n", secure=secure)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class ClientAdapter(ABC):
|
|
155
|
+
key: str
|
|
156
|
+
|
|
157
|
+
@abstractmethod
|
|
158
|
+
def detect(self) -> bool:
|
|
159
|
+
"""Whether this client appears to be installed or configured on this machine."""
|
|
160
|
+
|
|
161
|
+
@abstractmethod
|
|
162
|
+
def read_existing(self, server_name: str) -> Optional[ExistingEntry]:
|
|
163
|
+
"""Return the existing MCP entry for server_name, if any."""
|
|
164
|
+
|
|
165
|
+
@abstractmethod
|
|
166
|
+
def write(self, server_name: str, url: str, token: Optional[str]) -> WriteResult:
|
|
167
|
+
"""Create or replace the MCP entry for server_name. Must be idempotent."""
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Claude Code adapter.
|
|
2
|
+
|
|
3
|
+
Preferred write path: the `claude mcp add` CLI (the sanctioned interface; it owns config
|
|
4
|
+
placement). Fallback when the binary is missing: edit the config file for the requested
|
|
5
|
+
scope directly — ~/.claude.json (user scope) or <cwd>/.mcp.json (project scope).
|
|
6
|
+
|
|
7
|
+
Reads follow Claude Code's same-name resolution precedence: local scope
|
|
8
|
+
(~/.claude.json projects.<cwd>.mcpServers) > project (.mcp.json) > user
|
|
9
|
+
(~/.claude.json mcpServers). Getting this order right matters: the entry this
|
|
10
|
+
adapter reports is the one Claude Code will actually use, which is how connect
|
|
11
|
+
detects stale shadowing entries after a write.
|
|
12
|
+
|
|
13
|
+
Note: the CLI write path passes the Authorization header via argv, which is transiently
|
|
14
|
+
visible in the local process list. The file fallback avoids this; both paths end with a
|
|
15
|
+
read-back so a write that did not take effect is reported, never assumed.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
22
|
+
|
|
23
|
+
from agnoctl.clients.base import (
|
|
24
|
+
ClientAdapter,
|
|
25
|
+
ExistingEntry,
|
|
26
|
+
WriteResult,
|
|
27
|
+
bearer_header,
|
|
28
|
+
read_json_lenient,
|
|
29
|
+
servers_table,
|
|
30
|
+
token_from_authorization,
|
|
31
|
+
write_servers_entry,
|
|
32
|
+
)
|
|
33
|
+
from agnoctl.errors import CLIError
|
|
34
|
+
|
|
35
|
+
SUBPROCESS_TIMEOUT = 60.0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _redact_token(text: str, token: Optional[str]) -> str:
|
|
39
|
+
"""Strip the bearer token (raw and as an Authorization value) out of a string before
|
|
40
|
+
it reaches a user-facing error or JSON output."""
|
|
41
|
+
if not token:
|
|
42
|
+
return text
|
|
43
|
+
return text.replace(bearer_header(token), "Bearer ***").replace(token, "***")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _entry_from_servers(servers: Dict[str, Any], server_name: str, location: str) -> Optional[ExistingEntry]:
|
|
47
|
+
entry = servers.get(server_name)
|
|
48
|
+
if not isinstance(entry, dict):
|
|
49
|
+
return None
|
|
50
|
+
url = entry.get("url")
|
|
51
|
+
if not isinstance(url, str) or not url:
|
|
52
|
+
return None
|
|
53
|
+
headers = entry.get("headers")
|
|
54
|
+
if not isinstance(headers, dict):
|
|
55
|
+
headers = {}
|
|
56
|
+
return ExistingEntry(url=url, token=token_from_authorization(headers.get("Authorization")), location=location)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ClaudeCodeAdapter(ClientAdapter):
|
|
60
|
+
key = "claude-code"
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
home: Optional[Path] = None,
|
|
65
|
+
cwd: Optional[Path] = None,
|
|
66
|
+
scope: str = "user",
|
|
67
|
+
which: Callable[[str], Optional[str]] = shutil.which,
|
|
68
|
+
runner: Callable[..., "subprocess.CompletedProcess[str]"] = subprocess.run,
|
|
69
|
+
):
|
|
70
|
+
self.home = home or Path.home()
|
|
71
|
+
self.cwd = cwd or Path.cwd()
|
|
72
|
+
self.scope = scope
|
|
73
|
+
self._which = which
|
|
74
|
+
self._runner = runner
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def _user_config_path(self) -> Path:
|
|
78
|
+
return self.home / ".claude.json"
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def _project_config_path(self) -> Path:
|
|
82
|
+
return self.cwd / ".mcp.json"
|
|
83
|
+
|
|
84
|
+
def detect(self) -> bool:
|
|
85
|
+
return self._which("claude") is not None or self._user_config_path.exists()
|
|
86
|
+
|
|
87
|
+
def read_existing(self, server_name: str) -> Optional[ExistingEntry]:
|
|
88
|
+
"""Return the entry Claude Code would resolve: local > project > user scope."""
|
|
89
|
+
user_config = read_json_lenient(self._user_config_path)
|
|
90
|
+
|
|
91
|
+
if user_config:
|
|
92
|
+
projects = user_config.get("projects")
|
|
93
|
+
if isinstance(projects, dict):
|
|
94
|
+
project = projects.get(str(self.cwd))
|
|
95
|
+
if isinstance(project, dict):
|
|
96
|
+
entry = _entry_from_servers(
|
|
97
|
+
servers_table(project), server_name, str(self._user_config_path) + " (local scope)"
|
|
98
|
+
)
|
|
99
|
+
if entry:
|
|
100
|
+
return entry
|
|
101
|
+
|
|
102
|
+
project_config = read_json_lenient(self._project_config_path)
|
|
103
|
+
if project_config:
|
|
104
|
+
entry = _entry_from_servers(servers_table(project_config), server_name, str(self._project_config_path))
|
|
105
|
+
if entry:
|
|
106
|
+
return entry
|
|
107
|
+
|
|
108
|
+
if user_config:
|
|
109
|
+
entry = _entry_from_servers(
|
|
110
|
+
servers_table(user_config), server_name, str(self._user_config_path) + " (user scope)"
|
|
111
|
+
)
|
|
112
|
+
if entry:
|
|
113
|
+
return entry
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
def write(self, server_name: str, url: str, token: Optional[str]) -> WriteResult:
|
|
117
|
+
if self._which("claude") is not None:
|
|
118
|
+
self._write_via_cli(server_name, url, token)
|
|
119
|
+
return WriteResult(method="cli", location="claude mcp add (scope: " + self.scope + ")")
|
|
120
|
+
if self.scope == "project":
|
|
121
|
+
return self._write_config_file(self._project_config_path, server_name, url, token)
|
|
122
|
+
return self._write_config_file(self._user_config_path, server_name, url, token)
|
|
123
|
+
|
|
124
|
+
# -- CLI path ---------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
def _run_claude(self, args: List[str]) -> "subprocess.CompletedProcess[str]":
|
|
127
|
+
try:
|
|
128
|
+
return self._runner(
|
|
129
|
+
args,
|
|
130
|
+
capture_output=True,
|
|
131
|
+
text=True,
|
|
132
|
+
timeout=SUBPROCESS_TIMEOUT,
|
|
133
|
+
stdin=subprocess.DEVNULL,
|
|
134
|
+
)
|
|
135
|
+
except subprocess.TimeoutExpired:
|
|
136
|
+
raise CLIError("The claude CLI did not respond within " + str(int(SUBPROCESS_TIMEOUT)) + "s: " + args[2])
|
|
137
|
+
|
|
138
|
+
def _write_via_cli(self, server_name: str, url: str, token: Optional[str]) -> None:
|
|
139
|
+
# Variadic flags (--header) must come after the name and URL, or Claude Code's
|
|
140
|
+
# parser consumes the positional arguments.
|
|
141
|
+
add_args: List[str] = ["claude", "mcp", "add", "--transport", "http", "--scope", self.scope, server_name, url]
|
|
142
|
+
if token:
|
|
143
|
+
add_args += ["--header", "Authorization: " + bearer_header(token)]
|
|
144
|
+
|
|
145
|
+
result = self._run_claude(add_args)
|
|
146
|
+
if result.returncode != 0 and "already exists" in (result.stderr or "").lower():
|
|
147
|
+
remove = self._run_claude(["claude", "mcp", "remove", "--scope", self.scope, server_name])
|
|
148
|
+
if remove.returncode == 0:
|
|
149
|
+
result = self._run_claude(add_args)
|
|
150
|
+
if result.returncode != 0:
|
|
151
|
+
detail = (result.stderr or result.stdout or "").strip()
|
|
152
|
+
# The token rode in via argv; if the CLI echoed the command back in its error,
|
|
153
|
+
# keep the secret out of the message we print / return as JSON.
|
|
154
|
+
raise CLIError("claude mcp add failed: " + (_redact_token(detail, token) or "unknown error"))
|
|
155
|
+
|
|
156
|
+
# -- File fallback ------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
def _write_config_file(self, path: Path, server_name: str, url: str, token: Optional[str]) -> WriteResult:
|
|
159
|
+
entry: Dict[str, Any] = {"type": "http", "url": url}
|
|
160
|
+
if token:
|
|
161
|
+
entry["headers"] = {"Authorization": bearer_header(token)}
|
|
162
|
+
write_servers_entry(path, server_name, entry, secure=bool(token))
|
|
163
|
+
note = None
|
|
164
|
+
if path == self._project_config_path and token:
|
|
165
|
+
note = str(path) + " is project-scoped and often committed to version control; it now contains a token."
|
|
166
|
+
return WriteResult(method="file", location=str(path), note=note)
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Claude Desktop adapter.
|
|
2
|
+
|
|
3
|
+
Claude Desktop reads MCP servers from claude_desktop_config.json, but that file is
|
|
4
|
+
stdio-only: it launches each server as a subprocess and speaks JSON-RPC over stdio, so
|
|
5
|
+
it cannot point at a remote HTTP AgentOS directly. The supported bridge is `mcp-remote`
|
|
6
|
+
(run via npx), a small stdio<->HTTP proxy that every remote-MCP vendor documents for
|
|
7
|
+
this client. We write an entry that launches it with the AgentOS URL and, when auth is
|
|
8
|
+
on, an Authorization header sourced from an env var so the token stays out of argv:
|
|
9
|
+
|
|
10
|
+
"agno": {
|
|
11
|
+
"command": "npx",
|
|
12
|
+
"args": ["-y", "mcp-remote", "<url>", "--header", "Authorization:${AGNO_AUTH_HEADER}"],
|
|
13
|
+
"env": {"AGNO_AUTH_HEADER": "Bearer <token>"}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
Config path is per-OS (macOS Application Support, Windows APPDATA, Linux ~/.config).
|
|
17
|
+
Because the bridge runs at Claude Desktop launch time, it needs Node/npx on PATH; connect
|
|
18
|
+
verifies the AgentOS endpoint itself, not that Claude can spawn npx, so a missing npx is
|
|
19
|
+
surfaced as a note rather than a failure.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
import shutil
|
|
25
|
+
import sys
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
28
|
+
|
|
29
|
+
from agnoctl.clients.base import (
|
|
30
|
+
ClientAdapter,
|
|
31
|
+
ExistingEntry,
|
|
32
|
+
WriteResult,
|
|
33
|
+
bearer_header,
|
|
34
|
+
read_json_lenient,
|
|
35
|
+
servers_table,
|
|
36
|
+
token_from_authorization,
|
|
37
|
+
write_servers_entry,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# The env var the written entry reads the Authorization header from.
|
|
41
|
+
AUTH_ENV_VAR = "AGNO_AUTH_HEADER"
|
|
42
|
+
|
|
43
|
+
_ENV_REF = re.compile(r"\$\{(\w+)\}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _default_config_path(home: Path, platform: str) -> Path:
|
|
47
|
+
if platform == "darwin":
|
|
48
|
+
return home / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json"
|
|
49
|
+
if platform.startswith("win"):
|
|
50
|
+
appdata = os.environ.get("APPDATA")
|
|
51
|
+
base = Path(appdata) if appdata else home / "AppData" / "Roaming"
|
|
52
|
+
return base / "Claude" / "claude_desktop_config.json"
|
|
53
|
+
return home / ".config" / "Claude" / "claude_desktop_config.json"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _resolve_env_refs(value: str, env: Dict[str, Any]) -> str:
|
|
57
|
+
"""Expand ${VAR} references against the entry's own env table."""
|
|
58
|
+
|
|
59
|
+
def repl(match: "re.Match[str]") -> str:
|
|
60
|
+
replacement = env.get(match.group(1))
|
|
61
|
+
return replacement if isinstance(replacement, str) else match.group(0)
|
|
62
|
+
|
|
63
|
+
return _ENV_REF.sub(repl, value)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _token_from_bridge(args: List[Any], env: Dict[str, Any]) -> Optional[str]:
|
|
67
|
+
"""Pull the bearer token out of a `--header Authorization:...` mcp-remote arg."""
|
|
68
|
+
for i, arg in enumerate(args):
|
|
69
|
+
if arg == "--header" and i + 1 < len(args) and isinstance(args[i + 1], str):
|
|
70
|
+
name, sep, raw = args[i + 1].partition(":")
|
|
71
|
+
if sep and name.strip().lower() == "authorization":
|
|
72
|
+
return token_from_authorization(_resolve_env_refs(raw.strip(), env))
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _entry_from_config(entry: Any, location: str) -> Optional[ExistingEntry]:
|
|
77
|
+
if not isinstance(entry, dict):
|
|
78
|
+
return None
|
|
79
|
+
# Forward-compatible: if a future Claude Desktop writes a native remote entry.
|
|
80
|
+
url = entry.get("url")
|
|
81
|
+
if isinstance(url, str) and url:
|
|
82
|
+
headers = entry.get("headers")
|
|
83
|
+
header_map = headers if isinstance(headers, dict) else {}
|
|
84
|
+
return ExistingEntry(
|
|
85
|
+
url=url, token=token_from_authorization(header_map.get("Authorization")), location=location
|
|
86
|
+
)
|
|
87
|
+
# The mcp-remote bridge: first http(s) arg is the AgentOS URL.
|
|
88
|
+
args = entry.get("args")
|
|
89
|
+
if not isinstance(args, list):
|
|
90
|
+
return None
|
|
91
|
+
bridge_url = next(
|
|
92
|
+
(a for a in args if isinstance(a, str) and (a.startswith("http://") or a.startswith("https://"))), None
|
|
93
|
+
)
|
|
94
|
+
if not bridge_url:
|
|
95
|
+
return None
|
|
96
|
+
env = entry.get("env")
|
|
97
|
+
env_map = env if isinstance(env, dict) else {}
|
|
98
|
+
return ExistingEntry(url=bridge_url, token=_token_from_bridge(args, env_map), location=location)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class ClaudeDesktopAdapter(ClientAdapter):
|
|
102
|
+
key = "claude-desktop"
|
|
103
|
+
|
|
104
|
+
def __init__(
|
|
105
|
+
self,
|
|
106
|
+
home: Optional[Path] = None,
|
|
107
|
+
config_path: Optional[Path] = None,
|
|
108
|
+
platform: str = sys.platform,
|
|
109
|
+
which: Callable[[str], Optional[str]] = shutil.which,
|
|
110
|
+
):
|
|
111
|
+
self.home = home or Path.home()
|
|
112
|
+
self._config_path = config_path or _default_config_path(self.home, platform)
|
|
113
|
+
self._which = which
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def config_path(self) -> Path:
|
|
117
|
+
return self._config_path
|
|
118
|
+
|
|
119
|
+
def detect(self) -> bool:
|
|
120
|
+
return self.config_path.exists() or self.config_path.parent.is_dir()
|
|
121
|
+
|
|
122
|
+
def read_existing(self, server_name: str) -> Optional[ExistingEntry]:
|
|
123
|
+
config = read_json_lenient(self.config_path)
|
|
124
|
+
if config is None:
|
|
125
|
+
return None
|
|
126
|
+
return _entry_from_config(servers_table(config).get(server_name), str(self.config_path))
|
|
127
|
+
|
|
128
|
+
def write(self, server_name: str, url: str, token: Optional[str]) -> WriteResult:
|
|
129
|
+
args: List[str] = ["-y", "mcp-remote", url]
|
|
130
|
+
entry: Dict[str, Any] = {"command": "npx", "args": args}
|
|
131
|
+
if token:
|
|
132
|
+
args += ["--header", "Authorization:${" + AUTH_ENV_VAR + "}"]
|
|
133
|
+
entry["env"] = {AUTH_ENV_VAR: bearer_header(token)}
|
|
134
|
+
write_servers_entry(self.config_path, server_name, entry, secure=bool(token), mkdir=True)
|
|
135
|
+
|
|
136
|
+
note = None
|
|
137
|
+
if self._which("npx") is None:
|
|
138
|
+
note = (
|
|
139
|
+
"Claude Desktop launches this server with 'npx mcp-remote'; install Node.js/npx or it will not start."
|
|
140
|
+
)
|
|
141
|
+
return WriteResult(method="file", location=str(self.config_path), note=note)
|
agnoctl/clients/codex.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""OpenAI Codex adapter.
|
|
2
|
+
|
|
3
|
+
Codex reads MCP servers from ~/.codex/config.toml as [mcp_servers.<name>] tables. The
|
|
4
|
+
`codex mcp add` CLI cannot set static HTTP headers (only --bearer-token-env-var, which
|
|
5
|
+
requires the user to manage an environment variable), so this adapter edits the config
|
|
6
|
+
file directly, using a static http_headers table for zero-setup authentication.
|
|
7
|
+
|
|
8
|
+
The edit is a section-scoped text replacement: only lines belonging to
|
|
9
|
+
[mcp_servers.<name>] (and its dotted subtables) are touched, so user comments and other
|
|
10
|
+
servers survive. The result is validated by re-parsing before it is written.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Dict, List, Optional
|
|
17
|
+
|
|
18
|
+
from agnoctl.clients.base import (
|
|
19
|
+
ClientAdapter,
|
|
20
|
+
ExistingEntry,
|
|
21
|
+
WriteResult,
|
|
22
|
+
atomic_write_text,
|
|
23
|
+
bearer_header,
|
|
24
|
+
token_from_authorization,
|
|
25
|
+
)
|
|
26
|
+
from agnoctl.errors import CLIError
|
|
27
|
+
|
|
28
|
+
if sys.version_info >= (3, 11):
|
|
29
|
+
import tomllib
|
|
30
|
+
else:
|
|
31
|
+
import tomli as tomllib
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _toml_string(value: str) -> str:
|
|
35
|
+
# TOML basic strings share JSON's escaping rules for the characters that matter here.
|
|
36
|
+
return json.dumps(value)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class CodexAdapter(ClientAdapter):
|
|
40
|
+
key = "codex"
|
|
41
|
+
|
|
42
|
+
def __init__(self, home: Optional[Path] = None):
|
|
43
|
+
self.home = home or Path.home()
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def config_path(self) -> Path:
|
|
47
|
+
return self.home / ".codex" / "config.toml"
|
|
48
|
+
|
|
49
|
+
def detect(self) -> bool:
|
|
50
|
+
return (self.home / ".codex").is_dir()
|
|
51
|
+
|
|
52
|
+
def read_existing(self, server_name: str) -> Optional[ExistingEntry]:
|
|
53
|
+
parsed = self._parse_config()
|
|
54
|
+
if parsed is None:
|
|
55
|
+
return None
|
|
56
|
+
entry = (parsed.get("mcp_servers") or {}).get(server_name)
|
|
57
|
+
if not isinstance(entry, dict):
|
|
58
|
+
return None
|
|
59
|
+
url = entry.get("url")
|
|
60
|
+
if not isinstance(url, str) or not url:
|
|
61
|
+
return None
|
|
62
|
+
headers = entry.get("http_headers")
|
|
63
|
+
if not isinstance(headers, dict):
|
|
64
|
+
headers = {}
|
|
65
|
+
return ExistingEntry(
|
|
66
|
+
url=url,
|
|
67
|
+
token=token_from_authorization(headers.get("Authorization")),
|
|
68
|
+
location=str(self.config_path),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def write(self, server_name: str, url: str, token: Optional[str]) -> WriteResult:
|
|
72
|
+
block_lines = ["[mcp_servers." + server_name + "]", "url = " + _toml_string(url)]
|
|
73
|
+
if token:
|
|
74
|
+
block_lines.append(
|
|
75
|
+
"http_headers = { " + _toml_string("Authorization") + " = " + _toml_string(bearer_header(token)) + " }"
|
|
76
|
+
)
|
|
77
|
+
block = "\n".join(block_lines) + "\n"
|
|
78
|
+
|
|
79
|
+
existing_text = self.config_path.read_text() if self.config_path.exists() else ""
|
|
80
|
+
if existing_text:
|
|
81
|
+
try:
|
|
82
|
+
tomllib.loads(existing_text)
|
|
83
|
+
except tomllib.TOMLDecodeError as e:
|
|
84
|
+
raise CLIError(
|
|
85
|
+
"Refusing to modify "
|
|
86
|
+
+ str(self.config_path)
|
|
87
|
+
+ ": the existing TOML does not parse ("
|
|
88
|
+
+ str(e)
|
|
89
|
+
+ ").",
|
|
90
|
+
hint="Fix or move the file, then re-run.",
|
|
91
|
+
)
|
|
92
|
+
new_text = self._replace_section(existing_text, server_name, block)
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
tomllib.loads(new_text)
|
|
96
|
+
except tomllib.TOMLDecodeError as e:
|
|
97
|
+
raise CLIError(
|
|
98
|
+
"Refusing to write " + str(self.config_path) + ": the resulting TOML would be invalid (" + str(e) + ")."
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
atomic_write_text(self.config_path, new_text, secure=bool(token))
|
|
103
|
+
return WriteResult(method="file", location=str(self.config_path))
|
|
104
|
+
|
|
105
|
+
# -- Internals -----------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
def _parse_config(self) -> Optional[Dict[str, Any]]:
|
|
108
|
+
if not self.config_path.exists():
|
|
109
|
+
return None
|
|
110
|
+
try:
|
|
111
|
+
return tomllib.loads(self.config_path.read_text())
|
|
112
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def _replace_section(text: str, server_name: str, block: str) -> str:
|
|
117
|
+
"""Replace (or append) the [mcp_servers.<name>] section, including dotted subtables.
|
|
118
|
+
|
|
119
|
+
Every table header — [table] and [[array-of-tables]] alike — ends the previous
|
|
120
|
+
section, so content following the managed section is never swallowed. Trailing
|
|
121
|
+
comment/blank lines inside the managed section that lead into the next header
|
|
122
|
+
are preserved, since they usually describe what follows.
|
|
123
|
+
"""
|
|
124
|
+
section_prefix = "[mcp_servers." + server_name
|
|
125
|
+
lines = text.splitlines()
|
|
126
|
+
kept: List[str] = []
|
|
127
|
+
dropped: List[str] = []
|
|
128
|
+
insert_at: Optional[int] = None
|
|
129
|
+
in_section = False
|
|
130
|
+
|
|
131
|
+
def flush_trailing_comments() -> None:
|
|
132
|
+
trailing: List[str] = []
|
|
133
|
+
for dropped_line in reversed(dropped):
|
|
134
|
+
if dropped_line.strip() == "" or dropped_line.lstrip().startswith("#"):
|
|
135
|
+
trailing.append(dropped_line)
|
|
136
|
+
else:
|
|
137
|
+
break
|
|
138
|
+
kept.extend(reversed(trailing))
|
|
139
|
+
dropped.clear()
|
|
140
|
+
|
|
141
|
+
for line in lines:
|
|
142
|
+
stripped = line.strip()
|
|
143
|
+
if stripped.startswith("["):
|
|
144
|
+
owns = stripped.startswith(section_prefix + "]") or stripped.startswith(section_prefix + ".")
|
|
145
|
+
if in_section and not owns:
|
|
146
|
+
flush_trailing_comments()
|
|
147
|
+
in_section = owns
|
|
148
|
+
if owns:
|
|
149
|
+
if insert_at is None:
|
|
150
|
+
insert_at = len(kept)
|
|
151
|
+
continue
|
|
152
|
+
if in_section:
|
|
153
|
+
dropped.append(line)
|
|
154
|
+
else:
|
|
155
|
+
kept.append(line)
|
|
156
|
+
if in_section:
|
|
157
|
+
dropped.clear()
|
|
158
|
+
|
|
159
|
+
block_lines = block.rstrip("\n").splitlines()
|
|
160
|
+
if insert_at is not None:
|
|
161
|
+
kept[insert_at:insert_at] = block_lines
|
|
162
|
+
else:
|
|
163
|
+
if kept and kept[-1].strip():
|
|
164
|
+
kept.append("")
|
|
165
|
+
kept.extend(block_lines)
|
|
166
|
+
return "\n".join(kept).rstrip("\n") + "\n"
|