mcp-to-cli-kit 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. {mcp_to_cli_kit-0.1.0 → mcp_to_cli_kit-0.2.0}/PKG-INFO +63 -1
  2. {mcp_to_cli_kit-0.1.0 → mcp_to_cli_kit-0.2.0}/README.md +62 -0
  3. {mcp_to_cli_kit-0.1.0 → mcp_to_cli_kit-0.2.0}/src/mcp_to_cli_kit/cache.py +19 -4
  4. {mcp_to_cli_kit-0.1.0 → mcp_to_cli_kit-0.2.0}/src/mcp_to_cli_kit/cli.py +212 -45
  5. {mcp_to_cli_kit-0.1.0 → mcp_to_cli_kit-0.2.0}/src/mcp_to_cli_kit/client.py +44 -16
  6. {mcp_to_cli_kit-0.1.0 → mcp_to_cli_kit-0.2.0}/src/mcp_to_cli_kit/config.py +258 -16
  7. mcp_to_cli_kit-0.2.0/src/mcp_to_cli_kit/config_view.py +100 -0
  8. mcp_to_cli_kit-0.2.0/src/mcp_to_cli_kit/redaction.py +83 -0
  9. {mcp_to_cli_kit-0.1.0 → mcp_to_cli_kit-0.2.0}/tests/conftest.py +9 -0
  10. {mcp_to_cli_kit-0.1.0 → mcp_to_cli_kit-0.2.0}/tests/fixture_server.py +15 -0
  11. {mcp_to_cli_kit-0.1.0 → mcp_to_cli_kit-0.2.0}/tests/test_cli.py +30 -0
  12. mcp_to_cli_kit-0.2.0/tests/test_config_command.py +290 -0
  13. mcp_to_cli_kit-0.2.0/tests/test_expansion.py +520 -0
  14. {mcp_to_cli_kit-0.1.0 → mcp_to_cli_kit-0.2.0}/tests/test_mounts.py +1 -0
  15. mcp_to_cli_kit-0.2.0/tests/test_redaction.py +49 -0
  16. mcp_to_cli_kit-0.2.0/tests/test_strip_prefixes.py +379 -0
  17. {mcp_to_cli_kit-0.1.0 → mcp_to_cli_kit-0.2.0}/tests/test_tool_groups.py +1 -1
  18. {mcp_to_cli_kit-0.1.0 → mcp_to_cli_kit-0.2.0}/.editorconfig +0 -0
  19. {mcp_to_cli_kit-0.1.0 → mcp_to_cli_kit-0.2.0}/.github/workflows/ci.yml +0 -0
  20. {mcp_to_cli_kit-0.1.0 → mcp_to_cli_kit-0.2.0}/.github/workflows/publish.yml +0 -0
  21. {mcp_to_cli_kit-0.1.0 → mcp_to_cli_kit-0.2.0}/.gitignore +0 -0
  22. {mcp_to_cli_kit-0.1.0 → mcp_to_cli_kit-0.2.0}/.python-version +0 -0
  23. {mcp_to_cli_kit-0.1.0 → mcp_to_cli_kit-0.2.0}/LICENSE +0 -0
  24. {mcp_to_cli_kit-0.1.0 → mcp_to_cli_kit-0.2.0}/pyproject.toml +0 -0
  25. {mcp_to_cli_kit-0.1.0 → mcp_to_cli_kit-0.2.0}/src/mcp_to_cli_kit/__init__.py +0 -0
  26. {mcp_to_cli_kit-0.1.0 → mcp_to_cli_kit-0.2.0}/src/mcp_to_cli_kit/agent.py +0 -0
  27. {mcp_to_cli_kit-0.1.0 → mcp_to_cli_kit-0.2.0}/src/mcp_to_cli_kit/arguments.py +0 -0
  28. {mcp_to_cli_kit-0.1.0 → mcp_to_cli_kit-0.2.0}/src/mcp_to_cli_kit/output.py +0 -0
  29. {mcp_to_cli_kit-0.1.0 → mcp_to_cli_kit-0.2.0}/src/mcp_to_cli_kit/py.typed +0 -0
  30. {mcp_to_cli_kit-0.1.0 → mcp_to_cli_kit-0.2.0}/tests/test_agent.py +0 -0
  31. {mcp_to_cli_kit-0.1.0 → mcp_to_cli_kit-0.2.0}/tests/test_arguments.py +0 -0
  32. {mcp_to_cli_kit-0.1.0 → mcp_to_cli_kit-0.2.0}/tests/test_client.py +0 -0
  33. {mcp_to_cli_kit-0.1.0 → mcp_to_cli_kit-0.2.0}/tests/test_config.py +0 -0
  34. {mcp_to_cli_kit-0.1.0 → mcp_to_cli_kit-0.2.0}/tests/test_output.py +0 -0
  35. {mcp_to_cli_kit-0.1.0 → mcp_to_cli_kit-0.2.0}/tests/test_schema.py +0 -0
  36. {mcp_to_cli_kit-0.1.0 → mcp_to_cli_kit-0.2.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: mcp-to-cli-kit
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Build a typed command-line interface for one or more remote MCP servers
5
5
  Project-URL: Homepage, https://github.com/fprochazka/mcp-to-cli-kit
6
6
  Project-URL: Repository, https://github.com/fprochazka/mcp-to-cli-kit
@@ -149,6 +149,27 @@ servers:
149
149
  sse_read_timeout: 100 # seconds to wait for an answer, streamed or not
150
150
  ```
151
151
 
152
+ ### Environment variables in values
153
+
154
+ The `url` and the `headers` values may refer to environment variables as `${NAME}`, so a token can stay out of the config file and the code default:
155
+
156
+ ```yaml
157
+ servers:
158
+ example:
159
+ url: https://${EXAMPLE_HOST}/mcp
160
+ headers:
161
+ Authorization: "Bearer ${EXAMPLE_TOKEN}"
162
+ ```
163
+
164
+ - References are expanded when the CLI contacts the server, never when the config is loaded. An unset variable therefore fails only a command that needs that server, with a config error (exit code 2) that names the variable and the server, and never shows a value. An empty variable counts as set.
165
+ - `--help`, a tool's `--help` and `schema` still work offline from a fresh tool cache when the variable is unset. With an expired cache the CLI warns and uses it, as when the server is down.
166
+ - A template can come from any layer: the code default, the config file, or an environment variable such as `EXAMPLE_TOOL_SERVERS__EXAMPLE__HEADERS__AUTHORIZATION='Bearer ${EXAMPLE_TOKEN}'`.
167
+ - `$${` is a literal `${`. A `$` not followed by `{` stays as it is. A `${` without a closing `}`, or one that does not enclose a variable name (letters, digits and `_`), is a config error when the config is loaded.
168
+ - Only `url` and the header values are expanded. Names (servers, mounts, tool groups, tools) and help texts are taken as written.
169
+ - A URL with references is checked once it is expanded: it must be a valid http(s) URL, and no value may move text into another part of the URL (a `/`, `?`, `#` or `@` in a password, for example), so percent-encode such characters in the variable. An expanded header value must not hold control or non-ASCII characters. Each of these is a config error that names the variable, never its value.
170
+ - Error messages show the URL as configured, with `${NAME}` unexpanded and without its userinfo and query. The parts that carry secrets (the values of the variables in the headers, and the userinfo, password, query and fragment of the expanded URL, also in their percent-encoded forms, if they are 4 characters or longer) are also replaced in transport errors and in the `-v` / `-vv` log. Host, port and path values are not secrets and are shown.
171
+ - The tool cache remembers a hash of the URL as configured and one of the expanded URL. Pointing a variable at another server fetches that server's tools; with the variable unset, the cache of the configured URL is used.
172
+
152
173
  ### Several servers in one bin
153
174
 
154
175
  A bin can serve several MCP servers. Each server says where its tools appear with `mount`:
@@ -169,6 +190,22 @@ Each server keeps its own session, cache file (`<cache>/<bin>/<server>.json`), h
169
190
 
170
191
  Two servers must not produce the same command name. When the tool lists show that they do (two root servers with a tool of the same name, or a group named like a top-level tool), the CLI stops with a config error that names both servers; give one of them a group or prefix mount, or hide the tool with `hidden_tools`. Calling a group checks the other servers only from what is already known (their caches), so a clash with a server whose tools were never fetched surfaces in the top-level `--help`. When a command resolves on one server while another server that could also define it cannot be reached, the CLI runs it and warns that the other server could not be checked.
171
192
 
193
+ ### Stripping prefixes from tool names
194
+
195
+ Some servers put their own name in front of every tool (`vendor-x_list`, `vendor-x_get`). `strip_prefixes` removes it from the command names:
196
+
197
+ ```yaml
198
+ servers:
199
+ example:
200
+ url: https://mcp.example.com/mcp
201
+ strip_prefixes: [vendor-x_, vendor_x-] # vendor-x_list -> example-tool list
202
+ ```
203
+
204
+ - The first prefix in the list that a tool name starts with is stripped. A name that equals the prefix is kept.
205
+ - It works in every mount mode and happens before tool groups and mounts: `tool_groups` prefixes match the stripped names, and a group or prefix mount applies to the stripped command (`prefix: "v-"` gives `example-tool v-list`).
206
+ - The tool's own name stays an alias (`example-tool vendor-x_list`), and `hidden_tools` still lists tools by their own names.
207
+ - When a stripped name would clash with another tool or a tool group of the same server (`vendor-x_list` next to a plain `list`), that tool keeps its full name and the CLI warns on stderr, so neither tool hides the other; hide one of them with `hidden_tools` to silence it. A clash with another server's tool is a config error, as for any other command name.
208
+
172
209
  ### Tool groups and hidden tools
173
210
 
174
211
  A server with many tools can split them into subcommands by tool-name prefix:
@@ -210,6 +247,31 @@ A property named like a built-in option (`help`, `input-json`, `arg-file`, `json
210
247
 
211
248
  `schema` is a built-in command name: a tool whose command would be `schema` is renamed to `schema-tool`, and no group mount or tool group may be called `schema`.
212
249
 
250
+ ### The config command
251
+
252
+ `example-tool config` prints the effective configuration as JSON: the code default, the config file and the environment variables merged, with every default filled in. Next to it, it shows the config file that was looked for (`config_file`), whether it was loaded (`config_file_loaded`), the environment variable prefix (`env_prefix`) and the names of the environment variables that were applied (`env_applied`), never their values.
253
+
254
+ ```console
255
+ $ example-tool config --pretty
256
+ {
257
+ "config_file": "/home/me/.config/example-tool/config.yaml",
258
+ "config_file_loaded": true,
259
+ "env_prefix": "EXAMPLE_TOOL_",
260
+ "env_applied": ["EXAMPLE_TOOL_SERVERS__EXAMPLE__TIMEOUT"],
261
+ "config": {"help": null, "servers": {"example": {"url": "https://mcp.example.com/mcp", "headers": {"Authorization": "Bearer ${EXAMPLE_TOKEN}"}, ...}}}
262
+ }
263
+ ```
264
+
265
+ - It reads the configuration only. It loads no tool list, sends nothing to any server and needs no cache, so it works offline and with unset variables.
266
+ - `${NAME}` references are shown as written, never expanded.
267
+ - Literal text that may be a secret is masked as `***` plus its last 4 characters, or only `***` when it is shorter than 12 characters. These are masked:
268
+ - every header value, except `Accept`, `Content-Type`, `User-Agent` and `X-Mcp-Client` (matched in any case), which are shown as written. A leading `Bearer`, `Basic`, `Token`, `Digest`, `Bot` or `ApiKey` followed by more text stays readable, as in `Bearer ***abcd`;
269
+ - a URL's whole userinfo (user and password), its query values and its fragment.
270
+ - Shown as written: the URL's scheme, host, port and path, every other setting (timeouts, mounts, tool groups, hidden tools, prefixes, help texts), and all `${NAME}` references.
271
+ - The output follows the usual modes: compact under an AI agent or with `--json`, indented otherwise or with `--pretty`.
272
+
273
+ `config` is a reserved command name, like `schema`: a tool whose command would be `config` is renamed to `config-tool`, and no group mount or tool group may be called `config`.
274
+
213
275
  ### Output and exit codes
214
276
 
215
277
  Under an AI coding agent (detected from its environment variables), or with `--json`, the result is printed exactly as the server sent it. Otherwise, or with `--pretty`, JSON is indented with `indent=2` and non-ASCII text stays readable. Several content items become one JSON array. Nothing is ever wrapped to the terminal width.
@@ -117,6 +117,27 @@ servers:
117
117
  sse_read_timeout: 100 # seconds to wait for an answer, streamed or not
118
118
  ```
119
119
 
120
+ ### Environment variables in values
121
+
122
+ The `url` and the `headers` values may refer to environment variables as `${NAME}`, so a token can stay out of the config file and the code default:
123
+
124
+ ```yaml
125
+ servers:
126
+ example:
127
+ url: https://${EXAMPLE_HOST}/mcp
128
+ headers:
129
+ Authorization: "Bearer ${EXAMPLE_TOKEN}"
130
+ ```
131
+
132
+ - References are expanded when the CLI contacts the server, never when the config is loaded. An unset variable therefore fails only a command that needs that server, with a config error (exit code 2) that names the variable and the server, and never shows a value. An empty variable counts as set.
133
+ - `--help`, a tool's `--help` and `schema` still work offline from a fresh tool cache when the variable is unset. With an expired cache the CLI warns and uses it, as when the server is down.
134
+ - A template can come from any layer: the code default, the config file, or an environment variable such as `EXAMPLE_TOOL_SERVERS__EXAMPLE__HEADERS__AUTHORIZATION='Bearer ${EXAMPLE_TOKEN}'`.
135
+ - `$${` is a literal `${`. A `$` not followed by `{` stays as it is. A `${` without a closing `}`, or one that does not enclose a variable name (letters, digits and `_`), is a config error when the config is loaded.
136
+ - Only `url` and the header values are expanded. Names (servers, mounts, tool groups, tools) and help texts are taken as written.
137
+ - A URL with references is checked once it is expanded: it must be a valid http(s) URL, and no value may move text into another part of the URL (a `/`, `?`, `#` or `@` in a password, for example), so percent-encode such characters in the variable. An expanded header value must not hold control or non-ASCII characters. Each of these is a config error that names the variable, never its value.
138
+ - Error messages show the URL as configured, with `${NAME}` unexpanded and without its userinfo and query. The parts that carry secrets (the values of the variables in the headers, and the userinfo, password, query and fragment of the expanded URL, also in their percent-encoded forms, if they are 4 characters or longer) are also replaced in transport errors and in the `-v` / `-vv` log. Host, port and path values are not secrets and are shown.
139
+ - The tool cache remembers a hash of the URL as configured and one of the expanded URL. Pointing a variable at another server fetches that server's tools; with the variable unset, the cache of the configured URL is used.
140
+
120
141
  ### Several servers in one bin
121
142
 
122
143
  A bin can serve several MCP servers. Each server says where its tools appear with `mount`:
@@ -137,6 +158,22 @@ Each server keeps its own session, cache file (`<cache>/<bin>/<server>.json`), h
137
158
 
138
159
  Two servers must not produce the same command name. When the tool lists show that they do (two root servers with a tool of the same name, or a group named like a top-level tool), the CLI stops with a config error that names both servers; give one of them a group or prefix mount, or hide the tool with `hidden_tools`. Calling a group checks the other servers only from what is already known (their caches), so a clash with a server whose tools were never fetched surfaces in the top-level `--help`. When a command resolves on one server while another server that could also define it cannot be reached, the CLI runs it and warns that the other server could not be checked.
139
160
 
161
+ ### Stripping prefixes from tool names
162
+
163
+ Some servers put their own name in front of every tool (`vendor-x_list`, `vendor-x_get`). `strip_prefixes` removes it from the command names:
164
+
165
+ ```yaml
166
+ servers:
167
+ example:
168
+ url: https://mcp.example.com/mcp
169
+ strip_prefixes: [vendor-x_, vendor_x-] # vendor-x_list -> example-tool list
170
+ ```
171
+
172
+ - The first prefix in the list that a tool name starts with is stripped. A name that equals the prefix is kept.
173
+ - It works in every mount mode and happens before tool groups and mounts: `tool_groups` prefixes match the stripped names, and a group or prefix mount applies to the stripped command (`prefix: "v-"` gives `example-tool v-list`).
174
+ - The tool's own name stays an alias (`example-tool vendor-x_list`), and `hidden_tools` still lists tools by their own names.
175
+ - When a stripped name would clash with another tool or a tool group of the same server (`vendor-x_list` next to a plain `list`), that tool keeps its full name and the CLI warns on stderr, so neither tool hides the other; hide one of them with `hidden_tools` to silence it. A clash with another server's tool is a config error, as for any other command name.
176
+
140
177
  ### Tool groups and hidden tools
141
178
 
142
179
  A server with many tools can split them into subcommands by tool-name prefix:
@@ -178,6 +215,31 @@ A property named like a built-in option (`help`, `input-json`, `arg-file`, `json
178
215
 
179
216
  `schema` is a built-in command name: a tool whose command would be `schema` is renamed to `schema-tool`, and no group mount or tool group may be called `schema`.
180
217
 
218
+ ### The config command
219
+
220
+ `example-tool config` prints the effective configuration as JSON: the code default, the config file and the environment variables merged, with every default filled in. Next to it, it shows the config file that was looked for (`config_file`), whether it was loaded (`config_file_loaded`), the environment variable prefix (`env_prefix`) and the names of the environment variables that were applied (`env_applied`), never their values.
221
+
222
+ ```console
223
+ $ example-tool config --pretty
224
+ {
225
+ "config_file": "/home/me/.config/example-tool/config.yaml",
226
+ "config_file_loaded": true,
227
+ "env_prefix": "EXAMPLE_TOOL_",
228
+ "env_applied": ["EXAMPLE_TOOL_SERVERS__EXAMPLE__TIMEOUT"],
229
+ "config": {"help": null, "servers": {"example": {"url": "https://mcp.example.com/mcp", "headers": {"Authorization": "Bearer ${EXAMPLE_TOKEN}"}, ...}}}
230
+ }
231
+ ```
232
+
233
+ - It reads the configuration only. It loads no tool list, sends nothing to any server and needs no cache, so it works offline and with unset variables.
234
+ - `${NAME}` references are shown as written, never expanded.
235
+ - Literal text that may be a secret is masked as `***` plus its last 4 characters, or only `***` when it is shorter than 12 characters. These are masked:
236
+ - every header value, except `Accept`, `Content-Type`, `User-Agent` and `X-Mcp-Client` (matched in any case), which are shown as written. A leading `Bearer`, `Basic`, `Token`, `Digest`, `Bot` or `ApiKey` followed by more text stays readable, as in `Bearer ***abcd`;
237
+ - a URL's whole userinfo (user and password), its query values and its fragment.
238
+ - Shown as written: the URL's scheme, host, port and path, every other setting (timeouts, mounts, tool groups, hidden tools, prefixes, help texts), and all `${NAME}` references.
239
+ - The output follows the usual modes: compact under an AI agent or with `--json`, indented otherwise or with `--pretty`.
240
+
241
+ `config` is a reserved command name, like `schema`: a tool whose command would be `config` is renamed to `config-tool`, and no group mount or tool group may be called `config`.
242
+
181
243
  ### Output and exit codes
182
244
 
183
245
  Under an AI coding agent (detected from its environment variables), or with `--json`, the result is printed exactly as the server sent it. Otherwise, or with `--pretty`, JSON is indented with `indent=2` and non-ASCII text stays readable. Several content items become one JSON array. Nothing is ever wrapped to the terminal width.
@@ -6,6 +6,11 @@ A cache entry is fresh for an hour. An expired entry is still kept: when the ser
6
6
  be reached, the CLI falls back to it rather than failing to know its own commands. The entry
7
7
  records a hash of the server URL, never the URL itself, which may carry a token; an entry
8
8
  written for another URL is ignored.
9
+
10
+ A configured URL may be a template with ``${NAME}`` references. The entry then records a hash
11
+ of the template and one of the expanded URL. When the variables are set, both must match, so
12
+ pointing a variable at another server does not reuse the old server's tools; when one is not
13
+ set, the template alone decides, so ``--help`` still lists the tools offline.
9
14
  """
10
15
 
11
16
  from __future__ import annotations
@@ -60,8 +65,14 @@ class SchemaCache:
60
65
  """The cache file of one server."""
61
66
  return self.directory / f"{server}.json"
62
67
 
63
- def load(self, server: str, url: str) -> CachedTools | None:
64
- """Return the cached tools of a server, fresh or expired, or None when there is no usable entry."""
68
+ def load(self, server: str, url: str, resolved_url: str | None = None) -> CachedTools | None:
69
+ """Return the cached tools of a server, fresh or expired, or None when there is no usable entry.
70
+
71
+ Args:
72
+ server: The server's name.
73
+ url: The URL as configured, a template or not.
74
+ resolved_url: The URL with its variables expanded, or None when they cannot be.
75
+ """
65
76
  path = self.path(server)
66
77
  try:
67
78
  content = json.loads(path.read_text(encoding="utf-8"))
@@ -75,6 +86,7 @@ class SchemaCache:
75
86
  not isinstance(content, dict)
76
87
  or content.get("version") != _FORMAT_VERSION
77
88
  or content.get("url_sha256") != _url_hash(url)
89
+ or (resolved_url not in (None, url) and content.get("resolved_url_sha256") != _url_hash(resolved_url))
78
90
  or not isinstance(content.get("tools"), list)
79
91
  or not isinstance(content.get("fetched_at"), int | float)
80
92
  ):
@@ -86,9 +98,12 @@ class SchemaCache:
86
98
  tools = [tool for tool in content["tools"] if isinstance(tool, dict)]
87
99
  return CachedTools(tools=tools, fetched_at=fetched_at, fresh=0 <= age < self.ttl_seconds)
88
100
 
89
- def save(self, server: str, url: str, tools: list[dict[str, Any]]) -> None:
101
+ def save(self, server: str, url: str, tools: list[dict[str, Any]], resolved_url: str | None = None) -> None:
90
102
  """Write a server's tools atomically. A failure to write is logged, never raised."""
91
- content = {"version": _FORMAT_VERSION, "url_sha256": _url_hash(url), "fetched_at": time.time(), "tools": tools}
103
+ content: dict[str, Any] = {"version": _FORMAT_VERSION, "url_sha256": _url_hash(url)}
104
+ if resolved_url is not None and resolved_url != url:
105
+ content["resolved_url_sha256"] = _url_hash(resolved_url)
106
+ content.update(fetched_at=time.time(), tools=tools)
92
107
  path = self.path(server)
93
108
  try:
94
109
  self.directory.mkdir(mode=0o700, parents=True, exist_ok=True)