mcp-to-cli-kit 0.1.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.
- mcp_to_cli_kit-0.1.0/.editorconfig +15 -0
- mcp_to_cli_kit-0.1.0/.github/workflows/ci.yml +49 -0
- mcp_to_cli_kit-0.1.0/.github/workflows/publish.yml +44 -0
- mcp_to_cli_kit-0.1.0/.gitignore +18 -0
- mcp_to_cli_kit-0.1.0/.python-version +1 -0
- mcp_to_cli_kit-0.1.0/LICENSE +21 -0
- mcp_to_cli_kit-0.1.0/PKG-INFO +238 -0
- mcp_to_cli_kit-0.1.0/README.md +206 -0
- mcp_to_cli_kit-0.1.0/pyproject.toml +79 -0
- mcp_to_cli_kit-0.1.0/src/mcp_to_cli_kit/__init__.py +1 -0
- mcp_to_cli_kit-0.1.0/src/mcp_to_cli_kit/agent.py +106 -0
- mcp_to_cli_kit-0.1.0/src/mcp_to_cli_kit/arguments.py +789 -0
- mcp_to_cli_kit-0.1.0/src/mcp_to_cli_kit/cache.py +115 -0
- mcp_to_cli_kit-0.1.0/src/mcp_to_cli_kit/cli.py +1011 -0
- mcp_to_cli_kit-0.1.0/src/mcp_to_cli_kit/client.py +543 -0
- mcp_to_cli_kit-0.1.0/src/mcp_to_cli_kit/config.py +480 -0
- mcp_to_cli_kit-0.1.0/src/mcp_to_cli_kit/output.py +125 -0
- mcp_to_cli_kit-0.1.0/src/mcp_to_cli_kit/py.typed +0 -0
- mcp_to_cli_kit-0.1.0/tests/conftest.py +84 -0
- mcp_to_cli_kit-0.1.0/tests/fixture_server.py +294 -0
- mcp_to_cli_kit-0.1.0/tests/test_agent.py +90 -0
- mcp_to_cli_kit-0.1.0/tests/test_arguments.py +381 -0
- mcp_to_cli_kit-0.1.0/tests/test_cli.py +779 -0
- mcp_to_cli_kit-0.1.0/tests/test_client.py +134 -0
- mcp_to_cli_kit-0.1.0/tests/test_config.py +634 -0
- mcp_to_cli_kit-0.1.0/tests/test_mounts.py +317 -0
- mcp_to_cli_kit-0.1.0/tests/test_output.py +114 -0
- mcp_to_cli_kit-0.1.0/tests/test_schema.py +137 -0
- mcp_to_cli_kit-0.1.0/tests/test_tool_groups.py +233 -0
- mcp_to_cli_kit-0.1.0/uv.lock +1228 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
root = true
|
|
2
|
+
|
|
3
|
+
[*]
|
|
4
|
+
charset = utf-8
|
|
5
|
+
end_of_line = lf
|
|
6
|
+
insert_final_newline = true
|
|
7
|
+
trim_trailing_whitespace = true
|
|
8
|
+
indent_style = space
|
|
9
|
+
indent_size = 4
|
|
10
|
+
|
|
11
|
+
[*.{yml,yaml,toml,json}]
|
|
12
|
+
indent_size = 2
|
|
13
|
+
|
|
14
|
+
[*.md]
|
|
15
|
+
trim_trailing_whitespace = false
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [master]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
lint:
|
|
10
|
+
name: Lint and type check
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v4
|
|
14
|
+
|
|
15
|
+
- uses: astral-sh/setup-uv@v7
|
|
16
|
+
with:
|
|
17
|
+
enable-cache: true
|
|
18
|
+
|
|
19
|
+
- name: Install dependencies
|
|
20
|
+
run: uv sync --locked --dev
|
|
21
|
+
|
|
22
|
+
- name: Ruff check
|
|
23
|
+
run: uv run ruff check --output-format github .
|
|
24
|
+
|
|
25
|
+
- name: Ruff format check
|
|
26
|
+
run: uv run ruff format --check .
|
|
27
|
+
|
|
28
|
+
- name: Mypy
|
|
29
|
+
run: uv run mypy --strict src
|
|
30
|
+
|
|
31
|
+
test:
|
|
32
|
+
name: Test (Python ${{ matrix.python-version }})
|
|
33
|
+
runs-on: ubuntu-latest
|
|
34
|
+
strategy:
|
|
35
|
+
matrix:
|
|
36
|
+
python-version: ["3.11", "3.12", "3.13", "3.14"]
|
|
37
|
+
steps:
|
|
38
|
+
- uses: actions/checkout@v4
|
|
39
|
+
|
|
40
|
+
- uses: astral-sh/setup-uv@v7
|
|
41
|
+
with:
|
|
42
|
+
python-version: ${{ matrix.python-version }}
|
|
43
|
+
enable-cache: true
|
|
44
|
+
|
|
45
|
+
- name: Install dependencies
|
|
46
|
+
run: uv sync --locked --dev
|
|
47
|
+
|
|
48
|
+
- name: Run tests
|
|
49
|
+
run: uv run pytest
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
build:
|
|
10
|
+
name: Build
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v4
|
|
14
|
+
with:
|
|
15
|
+
fetch-depth: 0
|
|
16
|
+
|
|
17
|
+
- uses: astral-sh/setup-uv@v7
|
|
18
|
+
with:
|
|
19
|
+
enable-cache: true
|
|
20
|
+
|
|
21
|
+
- name: Build package
|
|
22
|
+
run: uv build
|
|
23
|
+
|
|
24
|
+
- uses: actions/upload-artifact@v4
|
|
25
|
+
with:
|
|
26
|
+
name: dist
|
|
27
|
+
path: dist/
|
|
28
|
+
|
|
29
|
+
publish:
|
|
30
|
+
name: Publish
|
|
31
|
+
needs: build
|
|
32
|
+
runs-on: ubuntu-latest
|
|
33
|
+
environment:
|
|
34
|
+
name: pypi
|
|
35
|
+
url: https://pypi.org/p/mcp-to-cli-kit
|
|
36
|
+
permissions:
|
|
37
|
+
id-token: write
|
|
38
|
+
steps:
|
|
39
|
+
- uses: actions/download-artifact@v4
|
|
40
|
+
with:
|
|
41
|
+
name: dist
|
|
42
|
+
path: dist/
|
|
43
|
+
|
|
44
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.11
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Filip Procházka
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: mcp-to-cli-kit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Build a typed command-line interface for one or more remote MCP servers
|
|
5
|
+
Project-URL: Homepage, https://github.com/fprochazka/mcp-to-cli-kit
|
|
6
|
+
Project-URL: Repository, https://github.com/fprochazka/mcp-to-cli-kit
|
|
7
|
+
Project-URL: Issues, https://github.com/fprochazka/mcp-to-cli-kit/issues
|
|
8
|
+
Author-email: Filip Procházka <dev@fprochazka.cz>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ai-agent,cli,click,json-schema,mcp,model-context-protocol
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
22
|
+
Classifier: Topic :: Software Development :: User Interfaces
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.11
|
|
25
|
+
Requires-Dist: click<9,>=8.1
|
|
26
|
+
Requires-Dist: httpx<1,>=0.27
|
|
27
|
+
Requires-Dist: jsonschema<5,>=4.20
|
|
28
|
+
Requires-Dist: mcp<2,>=1.26
|
|
29
|
+
Requires-Dist: pydantic<3,>=2.7
|
|
30
|
+
Requires-Dist: pyyaml>=6.0
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# mcp-to-cli-kit
|
|
34
|
+
|
|
35
|
+
A small library for turning one or more remote MCP servers into a typed command-line tool. Each MCP tool becomes a subcommand with real options compiled from its JSON input schema, so a shell user or an AI coding agent can call it without hand-writing JSON-RPC.
|
|
36
|
+
|
|
37
|
+
The kit owns the parts every such CLI needs and tends to get wrong when it is copied from project to project: the mapping from JSON schema to options, the configuration loading, the schema cache, and output that stays valid JSON when piped. A consumer package only supplies its bin name and a default configuration.
|
|
38
|
+
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
Add the kit to the consumer package:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
uv add mcp-to-cli-kit
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Building a consumer package
|
|
48
|
+
|
|
49
|
+
A consumer is a small Python package with one console script. It names the bin, gives the default configuration, and hands both to the kit.
|
|
50
|
+
|
|
51
|
+
`pyproject.toml`:
|
|
52
|
+
|
|
53
|
+
```toml
|
|
54
|
+
[project]
|
|
55
|
+
name = "example-tool"
|
|
56
|
+
version = "0.1.0"
|
|
57
|
+
requires-python = ">=3.11"
|
|
58
|
+
dependencies = ["mcp-to-cli-kit"]
|
|
59
|
+
|
|
60
|
+
[project.scripts]
|
|
61
|
+
example-tool = "example_tool.cli:main"
|
|
62
|
+
|
|
63
|
+
[build-system]
|
|
64
|
+
requires = ["hatchling"]
|
|
65
|
+
build-backend = "hatchling.build"
|
|
66
|
+
|
|
67
|
+
[tool.hatch.build.targets.wheel]
|
|
68
|
+
packages = ["src/example_tool"]
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`src/example_tool/cli.py`:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from typing import Any
|
|
75
|
+
|
|
76
|
+
from mcp_to_cli_kit.cli import main as kit_main
|
|
77
|
+
|
|
78
|
+
DEFAULT_CONFIG: dict[str, Any] = {
|
|
79
|
+
"help": "Call the tools of the example MCP server.",
|
|
80
|
+
"servers": {"example": {"url": "https://mcp.example.com/mcp"}},
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def main() -> None:
|
|
85
|
+
kit_main("example-tool", DEFAULT_CONFIG, distribution="example-tool")
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`DEFAULT_CONFIG` has the same shape as the config file (see [Configuration](#configuration)), so a user's `~/.config/example-tool/config.yaml` can override any key of it.
|
|
89
|
+
|
|
90
|
+
`distribution` is the name of the consumer's own package. The kit reads its version from the package metadata and shows it in `--version`, in the `X-Mcp-Client: example-tool/<version> (mcp-to-cli-kit <version>)` header of every request, and in the `clientInfo` of the MCP `initialize` request. Without it, the kit looks for the package that installed the `example-tool` script, and falls back to `0.1.0`.
|
|
91
|
+
|
|
92
|
+
### Results reported as text
|
|
93
|
+
|
|
94
|
+
Some servers report a failure as an ordinary result instead of setting `isError`. A `result_classifier` turns such a result into an error: it is printed to stderr and the exit code is 1. The kit calls it only for results the server did not already mark as errors.
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from mcp import types
|
|
98
|
+
from mcp_to_cli_kit.cli import main as kit_main
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def is_error(server: str, tool: str, result: types.CallToolResult) -> bool:
|
|
102
|
+
first = next((item.text for item in result.content if isinstance(item, types.TextContent)), "")
|
|
103
|
+
return first.startswith("Error: HTTP ")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def main() -> None:
|
|
107
|
+
kit_main("example-tool", DEFAULT_CONFIG, distribution="example-tool", result_classifier=is_error)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Developing the kit and a consumer together
|
|
111
|
+
|
|
112
|
+
To change the kit and a consumer at the same time, point the consumer at a local checkout of the kit as an editable path dependency, then install the consumer as a uv tool:
|
|
113
|
+
|
|
114
|
+
```toml
|
|
115
|
+
[tool.uv.sources]
|
|
116
|
+
mcp-to-cli-kit = { path = "../mcp-to-cli-kit", editable = true }
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
```console
|
|
120
|
+
$ uv tool install -e .
|
|
121
|
+
$ example-tool --version
|
|
122
|
+
example-tool 0.1.0 (mcp-to-cli-kit 0.1.dev7+g1d1eb86b6)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Because both installs are editable, a change in the kit or in the consumer takes effect without reinstalling. The kit takes its version from git tags, so a checkout between releases reports a development version such as `0.1.dev7+g1d1eb86b6`; the editable install records it when it is installed, so run `uv tool install -e .` again to refresh it.
|
|
126
|
+
|
|
127
|
+
## Usage
|
|
128
|
+
|
|
129
|
+
Every tool of the server is then a subcommand, named in kebab case (`search_items` becomes `search-items`; the tool's own name works too):
|
|
130
|
+
|
|
131
|
+
```console
|
|
132
|
+
$ example-tool --help # the tools, from a one-hour cache
|
|
133
|
+
$ example-tool search-items --help # a tool's full description and options
|
|
134
|
+
$ example-tool schema search-items # the tool's raw input schema
|
|
135
|
+
$ example-tool search-items --query "red" --limit 5 --tags a --tags b --no-verbose
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### Configuration
|
|
139
|
+
|
|
140
|
+
The default from code is overridden by `~/.config/<bin>/config.yaml` (or the file given by `--config`), and that by environment variables with the bin's prefix, such as `EXAMPLE_TOOL_SERVERS__EXAMPLE__URL` or `EXAMPLE_TOOL_SERVERS__EXAMPLE__HEADERS__AUTHORIZATION`. Without a default and without a file, the CLI says which file it expected. See `mcp_to_cli_kit/config.py` for the full rules.
|
|
141
|
+
|
|
142
|
+
```yaml
|
|
143
|
+
servers:
|
|
144
|
+
example:
|
|
145
|
+
url: https://mcp.example.com/mcp
|
|
146
|
+
headers:
|
|
147
|
+
Authorization: Bearer ...
|
|
148
|
+
timeout: 30 # seconds to connect and to send a request
|
|
149
|
+
sse_read_timeout: 100 # seconds to wait for an answer, streamed or not
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Several servers in one bin
|
|
153
|
+
|
|
154
|
+
A bin can serve several MCP servers. Each server says where its tools appear with `mount`:
|
|
155
|
+
|
|
156
|
+
```yaml
|
|
157
|
+
servers:
|
|
158
|
+
alpha:
|
|
159
|
+
url: https://alpha.example.com/mcp # mount: root, the default: tools at the top level
|
|
160
|
+
beta:
|
|
161
|
+
url: https://beta.example.com/mcp
|
|
162
|
+
mount: {group: beta, help: The beta tools.} # example-tool beta search-items ...
|
|
163
|
+
gamma:
|
|
164
|
+
url: https://gamma.example.com/mcp
|
|
165
|
+
mount: {prefix: "g-"} # example-tool g-search-items ...
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Each server keeps its own session, cache file (`<cache>/<bin>/<server>.json`), headers and retries, and only the servers a command needs are contacted: `example-tool beta search-items` never talks to alpha. The top-level `--help` lists the groups without asking their servers, and lists the root and prefixed tools from the caches; a server that cannot be listed is shown under "Unavailable servers" while the others are still listed.
|
|
169
|
+
|
|
170
|
+
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
|
+
|
|
172
|
+
### Tool groups and hidden tools
|
|
173
|
+
|
|
174
|
+
A server with many tools can split them into subcommands by tool-name prefix:
|
|
175
|
+
|
|
176
|
+
```yaml
|
|
177
|
+
servers:
|
|
178
|
+
example:
|
|
179
|
+
url: https://mcp.example.com/mcp
|
|
180
|
+
tool_groups:
|
|
181
|
+
- {prefix: svc_a_, group: svc-a, help: Service A.} # svc_a_list -> example-tool svc-a list
|
|
182
|
+
- {prefix: svc_b_, group: svc-b} # svc_b_search -> example-tool svc-b search
|
|
183
|
+
hidden_tools: [svc_a_delete]
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
- The prefix is stripped from the command name inside the group. The tool's own name still works as an alias, inside the group (`svc-a svc_a_list`) and at the server's mount level (`example-tool svc_a_list`). The longest matching prefix wins.
|
|
187
|
+
- Tools that match no prefix stay at the server's mount level.
|
|
188
|
+
- Tool groups sit where the server is mounted: at the top level for a root mount, under the group for a group mount (`example-tool ex svc-a list`), and behind the prefix for a prefix mount, because the prefix applies to everything the server puts at the top level (`prefix: "b-"` gives `example-tool b-svc-a list`).
|
|
189
|
+
- A tool group name must not clash with a tool command of the same server, with a group mount, or with another server's tool or tool group; each clash is a config error.
|
|
190
|
+
- `hidden_tools` lists tools by their own names. A hidden tool is not listed and cannot be called, inside a tool group as well; a tool group stays listed even when all its tools are hidden.
|
|
191
|
+
|
|
192
|
+
### Options
|
|
193
|
+
|
|
194
|
+
Each property of a tool's input schema becomes an option:
|
|
195
|
+
|
|
196
|
+
- The option is the kebab-case name (`--start-time`). The schema's own spelling (`--startTime`) and the snake case (`--start_time`) are accepted too, and the schema's spelling is what is sent.
|
|
197
|
+
- An option left out is not sent; the server applies its own default, which the help shows.
|
|
198
|
+
- Booleans are `--flag` / `--no-flag`, so `false` can be sent.
|
|
199
|
+
- Enums are fixed choices, listed in the help.
|
|
200
|
+
- Arrays are repeated options, one item each, or one JSON array. Values are never split on commas.
|
|
201
|
+
- Objects take a JSON literal; invalid JSON is an error, not a string.
|
|
202
|
+
- `--input-json JSON|@FILE|-` passes all arguments as one JSON object; `--arg-file NAME=PATH` reads one value from a file, or from stdin with `-`.
|
|
203
|
+
- Arguments are validated against the schema before the call. A usage error lists the tool's parameters, so no `--help` round trip is needed.
|
|
204
|
+
|
|
205
|
+
A property named like a built-in option (`help`, `input-json`, `arg-file`, `json`, `pretty`) gets an `--arg-` prefix.
|
|
206
|
+
|
|
207
|
+
### The schema command
|
|
208
|
+
|
|
209
|
+
`schema TOOL` prints a tool's raw JSON input schema, as the server declared it in `tools/list`. It exists at every level where tools live (`example-tool schema search-items`, `example-tool beta schema search-items`, `example-tool svc-a schema list`) and accepts every name the tool's own command accepts. It reads the cached tool list, so with a warm cache nothing is sent to the server. The schema is printed as compact JSON in machine mode and indented otherwise.
|
|
210
|
+
|
|
211
|
+
`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
|
+
|
|
213
|
+
### Output and exit codes
|
|
214
|
+
|
|
215
|
+
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.
|
|
216
|
+
|
|
217
|
+
| Exit code | Meaning |
|
|
218
|
+
|---|---|
|
|
219
|
+
| 0 | Success |
|
|
220
|
+
| 1 | The tool reported an error; its text is on stderr |
|
|
221
|
+
| 2 | Usage or configuration error |
|
|
222
|
+
| 3 | The server could not be reached or did not speak MCP |
|
|
223
|
+
|
|
224
|
+
Other global options: `--refresh-tools` fetches the tool list again, `-v` / `-vv` log to stderr, `--version`. Every request carries an `X-Mcp-Client: <bin>/<version> (mcp-to-cli-kit <version>)` header.
|
|
225
|
+
|
|
226
|
+
When the server cannot be reached, an expired tool cache is used with a warning. `initialize` and `tools/list` are retried twice on connect errors, timeouts and HTTP 502/503/504; a tool call is retried only when it never reached the server. Ctrl-C abandons the request in flight and exits with 130.
|
|
227
|
+
|
|
228
|
+
Config and cache live under `$XDG_CONFIG_HOME` and `$XDG_CACHE_HOME` when those are set, and under `~/.config` and `~/.cache` otherwise.
|
|
229
|
+
|
|
230
|
+
## Troubleshooting
|
|
231
|
+
|
|
232
|
+
- **A call ends with "timed out after 100s".** The server took longer than `sse_read_timeout`, which defaults to 100 seconds so that a coding agent's shell tool (often limited to 120 seconds) sees this error instead of killing the command. Raise `sse_read_timeout` for that server when its tools are legitimately slow. Timeouts exit with 3, never with 1, because the tool's own outcome is unknown.
|
|
233
|
+
- **A call hangs until that timeout although the server already failed.** When a server or proxy cuts a streamed (SSE) answer in the middle, the MCP SDK swallows the broken stream and offers no hook to notice it, so the request ends only with the read timeout. A plain JSON answer cut the same way fails at once.
|
|
234
|
+
- **Something else.** Run with `-v` to see what the CLI does, or `-vv` to also see the HTTP traffic and the MCP SDK's own log.
|
|
235
|
+
|
|
236
|
+
## License
|
|
237
|
+
|
|
238
|
+
MIT
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# mcp-to-cli-kit
|
|
2
|
+
|
|
3
|
+
A small library for turning one or more remote MCP servers into a typed command-line tool. Each MCP tool becomes a subcommand with real options compiled from its JSON input schema, so a shell user or an AI coding agent can call it without hand-writing JSON-RPC.
|
|
4
|
+
|
|
5
|
+
The kit owns the parts every such CLI needs and tends to get wrong when it is copied from project to project: the mapping from JSON schema to options, the configuration loading, the schema cache, and output that stays valid JSON when piped. A consumer package only supplies its bin name and a default configuration.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
Add the kit to the consumer package:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
uv add mcp-to-cli-kit
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Building a consumer package
|
|
16
|
+
|
|
17
|
+
A consumer is a small Python package with one console script. It names the bin, gives the default configuration, and hands both to the kit.
|
|
18
|
+
|
|
19
|
+
`pyproject.toml`:
|
|
20
|
+
|
|
21
|
+
```toml
|
|
22
|
+
[project]
|
|
23
|
+
name = "example-tool"
|
|
24
|
+
version = "0.1.0"
|
|
25
|
+
requires-python = ">=3.11"
|
|
26
|
+
dependencies = ["mcp-to-cli-kit"]
|
|
27
|
+
|
|
28
|
+
[project.scripts]
|
|
29
|
+
example-tool = "example_tool.cli:main"
|
|
30
|
+
|
|
31
|
+
[build-system]
|
|
32
|
+
requires = ["hatchling"]
|
|
33
|
+
build-backend = "hatchling.build"
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.wheel]
|
|
36
|
+
packages = ["src/example_tool"]
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`src/example_tool/cli.py`:
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from typing import Any
|
|
43
|
+
|
|
44
|
+
from mcp_to_cli_kit.cli import main as kit_main
|
|
45
|
+
|
|
46
|
+
DEFAULT_CONFIG: dict[str, Any] = {
|
|
47
|
+
"help": "Call the tools of the example MCP server.",
|
|
48
|
+
"servers": {"example": {"url": "https://mcp.example.com/mcp"}},
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def main() -> None:
|
|
53
|
+
kit_main("example-tool", DEFAULT_CONFIG, distribution="example-tool")
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`DEFAULT_CONFIG` has the same shape as the config file (see [Configuration](#configuration)), so a user's `~/.config/example-tool/config.yaml` can override any key of it.
|
|
57
|
+
|
|
58
|
+
`distribution` is the name of the consumer's own package. The kit reads its version from the package metadata and shows it in `--version`, in the `X-Mcp-Client: example-tool/<version> (mcp-to-cli-kit <version>)` header of every request, and in the `clientInfo` of the MCP `initialize` request. Without it, the kit looks for the package that installed the `example-tool` script, and falls back to `0.1.0`.
|
|
59
|
+
|
|
60
|
+
### Results reported as text
|
|
61
|
+
|
|
62
|
+
Some servers report a failure as an ordinary result instead of setting `isError`. A `result_classifier` turns such a result into an error: it is printed to stderr and the exit code is 1. The kit calls it only for results the server did not already mark as errors.
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from mcp import types
|
|
66
|
+
from mcp_to_cli_kit.cli import main as kit_main
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def is_error(server: str, tool: str, result: types.CallToolResult) -> bool:
|
|
70
|
+
first = next((item.text for item in result.content if isinstance(item, types.TextContent)), "")
|
|
71
|
+
return first.startswith("Error: HTTP ")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def main() -> None:
|
|
75
|
+
kit_main("example-tool", DEFAULT_CONFIG, distribution="example-tool", result_classifier=is_error)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Developing the kit and a consumer together
|
|
79
|
+
|
|
80
|
+
To change the kit and a consumer at the same time, point the consumer at a local checkout of the kit as an editable path dependency, then install the consumer as a uv tool:
|
|
81
|
+
|
|
82
|
+
```toml
|
|
83
|
+
[tool.uv.sources]
|
|
84
|
+
mcp-to-cli-kit = { path = "../mcp-to-cli-kit", editable = true }
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
```console
|
|
88
|
+
$ uv tool install -e .
|
|
89
|
+
$ example-tool --version
|
|
90
|
+
example-tool 0.1.0 (mcp-to-cli-kit 0.1.dev7+g1d1eb86b6)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Because both installs are editable, a change in the kit or in the consumer takes effect without reinstalling. The kit takes its version from git tags, so a checkout between releases reports a development version such as `0.1.dev7+g1d1eb86b6`; the editable install records it when it is installed, so run `uv tool install -e .` again to refresh it.
|
|
94
|
+
|
|
95
|
+
## Usage
|
|
96
|
+
|
|
97
|
+
Every tool of the server is then a subcommand, named in kebab case (`search_items` becomes `search-items`; the tool's own name works too):
|
|
98
|
+
|
|
99
|
+
```console
|
|
100
|
+
$ example-tool --help # the tools, from a one-hour cache
|
|
101
|
+
$ example-tool search-items --help # a tool's full description and options
|
|
102
|
+
$ example-tool schema search-items # the tool's raw input schema
|
|
103
|
+
$ example-tool search-items --query "red" --limit 5 --tags a --tags b --no-verbose
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Configuration
|
|
107
|
+
|
|
108
|
+
The default from code is overridden by `~/.config/<bin>/config.yaml` (or the file given by `--config`), and that by environment variables with the bin's prefix, such as `EXAMPLE_TOOL_SERVERS__EXAMPLE__URL` or `EXAMPLE_TOOL_SERVERS__EXAMPLE__HEADERS__AUTHORIZATION`. Without a default and without a file, the CLI says which file it expected. See `mcp_to_cli_kit/config.py` for the full rules.
|
|
109
|
+
|
|
110
|
+
```yaml
|
|
111
|
+
servers:
|
|
112
|
+
example:
|
|
113
|
+
url: https://mcp.example.com/mcp
|
|
114
|
+
headers:
|
|
115
|
+
Authorization: Bearer ...
|
|
116
|
+
timeout: 30 # seconds to connect and to send a request
|
|
117
|
+
sse_read_timeout: 100 # seconds to wait for an answer, streamed or not
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Several servers in one bin
|
|
121
|
+
|
|
122
|
+
A bin can serve several MCP servers. Each server says where its tools appear with `mount`:
|
|
123
|
+
|
|
124
|
+
```yaml
|
|
125
|
+
servers:
|
|
126
|
+
alpha:
|
|
127
|
+
url: https://alpha.example.com/mcp # mount: root, the default: tools at the top level
|
|
128
|
+
beta:
|
|
129
|
+
url: https://beta.example.com/mcp
|
|
130
|
+
mount: {group: beta, help: The beta tools.} # example-tool beta search-items ...
|
|
131
|
+
gamma:
|
|
132
|
+
url: https://gamma.example.com/mcp
|
|
133
|
+
mount: {prefix: "g-"} # example-tool g-search-items ...
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Each server keeps its own session, cache file (`<cache>/<bin>/<server>.json`), headers and retries, and only the servers a command needs are contacted: `example-tool beta search-items` never talks to alpha. The top-level `--help` lists the groups without asking their servers, and lists the root and prefixed tools from the caches; a server that cannot be listed is shown under "Unavailable servers" while the others are still listed.
|
|
137
|
+
|
|
138
|
+
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
|
+
|
|
140
|
+
### Tool groups and hidden tools
|
|
141
|
+
|
|
142
|
+
A server with many tools can split them into subcommands by tool-name prefix:
|
|
143
|
+
|
|
144
|
+
```yaml
|
|
145
|
+
servers:
|
|
146
|
+
example:
|
|
147
|
+
url: https://mcp.example.com/mcp
|
|
148
|
+
tool_groups:
|
|
149
|
+
- {prefix: svc_a_, group: svc-a, help: Service A.} # svc_a_list -> example-tool svc-a list
|
|
150
|
+
- {prefix: svc_b_, group: svc-b} # svc_b_search -> example-tool svc-b search
|
|
151
|
+
hidden_tools: [svc_a_delete]
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
- The prefix is stripped from the command name inside the group. The tool's own name still works as an alias, inside the group (`svc-a svc_a_list`) and at the server's mount level (`example-tool svc_a_list`). The longest matching prefix wins.
|
|
155
|
+
- Tools that match no prefix stay at the server's mount level.
|
|
156
|
+
- Tool groups sit where the server is mounted: at the top level for a root mount, under the group for a group mount (`example-tool ex svc-a list`), and behind the prefix for a prefix mount, because the prefix applies to everything the server puts at the top level (`prefix: "b-"` gives `example-tool b-svc-a list`).
|
|
157
|
+
- A tool group name must not clash with a tool command of the same server, with a group mount, or with another server's tool or tool group; each clash is a config error.
|
|
158
|
+
- `hidden_tools` lists tools by their own names. A hidden tool is not listed and cannot be called, inside a tool group as well; a tool group stays listed even when all its tools are hidden.
|
|
159
|
+
|
|
160
|
+
### Options
|
|
161
|
+
|
|
162
|
+
Each property of a tool's input schema becomes an option:
|
|
163
|
+
|
|
164
|
+
- The option is the kebab-case name (`--start-time`). The schema's own spelling (`--startTime`) and the snake case (`--start_time`) are accepted too, and the schema's spelling is what is sent.
|
|
165
|
+
- An option left out is not sent; the server applies its own default, which the help shows.
|
|
166
|
+
- Booleans are `--flag` / `--no-flag`, so `false` can be sent.
|
|
167
|
+
- Enums are fixed choices, listed in the help.
|
|
168
|
+
- Arrays are repeated options, one item each, or one JSON array. Values are never split on commas.
|
|
169
|
+
- Objects take a JSON literal; invalid JSON is an error, not a string.
|
|
170
|
+
- `--input-json JSON|@FILE|-` passes all arguments as one JSON object; `--arg-file NAME=PATH` reads one value from a file, or from stdin with `-`.
|
|
171
|
+
- Arguments are validated against the schema before the call. A usage error lists the tool's parameters, so no `--help` round trip is needed.
|
|
172
|
+
|
|
173
|
+
A property named like a built-in option (`help`, `input-json`, `arg-file`, `json`, `pretty`) gets an `--arg-` prefix.
|
|
174
|
+
|
|
175
|
+
### The schema command
|
|
176
|
+
|
|
177
|
+
`schema TOOL` prints a tool's raw JSON input schema, as the server declared it in `tools/list`. It exists at every level where tools live (`example-tool schema search-items`, `example-tool beta schema search-items`, `example-tool svc-a schema list`) and accepts every name the tool's own command accepts. It reads the cached tool list, so with a warm cache nothing is sent to the server. The schema is printed as compact JSON in machine mode and indented otherwise.
|
|
178
|
+
|
|
179
|
+
`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
|
+
|
|
181
|
+
### Output and exit codes
|
|
182
|
+
|
|
183
|
+
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.
|
|
184
|
+
|
|
185
|
+
| Exit code | Meaning |
|
|
186
|
+
|---|---|
|
|
187
|
+
| 0 | Success |
|
|
188
|
+
| 1 | The tool reported an error; its text is on stderr |
|
|
189
|
+
| 2 | Usage or configuration error |
|
|
190
|
+
| 3 | The server could not be reached or did not speak MCP |
|
|
191
|
+
|
|
192
|
+
Other global options: `--refresh-tools` fetches the tool list again, `-v` / `-vv` log to stderr, `--version`. Every request carries an `X-Mcp-Client: <bin>/<version> (mcp-to-cli-kit <version>)` header.
|
|
193
|
+
|
|
194
|
+
When the server cannot be reached, an expired tool cache is used with a warning. `initialize` and `tools/list` are retried twice on connect errors, timeouts and HTTP 502/503/504; a tool call is retried only when it never reached the server. Ctrl-C abandons the request in flight and exits with 130.
|
|
195
|
+
|
|
196
|
+
Config and cache live under `$XDG_CONFIG_HOME` and `$XDG_CACHE_HOME` when those are set, and under `~/.config` and `~/.cache` otherwise.
|
|
197
|
+
|
|
198
|
+
## Troubleshooting
|
|
199
|
+
|
|
200
|
+
- **A call ends with "timed out after 100s".** The server took longer than `sse_read_timeout`, which defaults to 100 seconds so that a coding agent's shell tool (often limited to 120 seconds) sees this error instead of killing the command. Raise `sse_read_timeout` for that server when its tools are legitimately slow. Timeouts exit with 3, never with 1, because the tool's own outcome is unknown.
|
|
201
|
+
- **A call hangs until that timeout although the server already failed.** When a server or proxy cuts a streamed (SSE) answer in the middle, the MCP SDK swallows the broken stream and offers no hook to notice it, so the request ends only with the read timeout. A plain JSON answer cut the same way fails at once.
|
|
202
|
+
- **Something else.** Run with `-v` to see what the CLI does, or `-vv` to also see the HTTP traffic and the MCP SDK's own log.
|
|
203
|
+
|
|
204
|
+
## License
|
|
205
|
+
|
|
206
|
+
MIT
|