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 ADDED
@@ -0,0 +1,8 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("agnoctl")
5
+ except PackageNotFoundError:
6
+ __version__ = "0.0.0"
7
+
8
+ __all__ = ["__version__"]
@@ -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
+ }
@@ -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)
@@ -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"