├── img ├── cmcp-logo.png ├── cmcp-logo-small.png └── cmcp-logo-medium.png ├── pyproject.toml ├── LICENSE ├── .github └── workflows │ └── ci.yml ├── README.md └── cmcp.py /img/cmcp-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RussellLuo/cmcp/HEAD/img/cmcp-logo.png -------------------------------------------------------------------------------- /img/cmcp-logo-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RussellLuo/cmcp/HEAD/img/cmcp-logo-small.png -------------------------------------------------------------------------------- /img/cmcp-logo-medium.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RussellLuo/cmcp/HEAD/img/cmcp-logo-medium.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "cmcp" 3 | version = "0.3.0" 4 | description = "A command-line utility for interacting with MCP servers." 5 | authors = ["RussellLuo "] 6 | readme = "README.md" 7 | repository = "https://github.com/RussellLuo/cmcp" 8 | 9 | [tool.poetry.dependencies] 10 | python = ">=3.10.0,<3.14" 11 | mcp = "1.13.0" 12 | pygments = "2.19.1" 13 | 14 | [tool.poetry.scripts] 15 | cmcp = "cmcp:main" 16 | 17 | [build-system] 18 | requires = ["poetry-core>=1.0.0"] 19 | build-backend = "poetry.core.masonry.api" 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Luo Peng 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. 22 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - '**' 9 | pull_request: {} 10 | 11 | permissions: 12 | contents: read 13 | 14 | jobs: 15 | release: 16 | if: "success() && startsWith(github.ref, 'refs/tags/')" 17 | runs-on: ubuntu-latest 18 | environment: release 19 | 20 | permissions: 21 | id-token: write 22 | 23 | steps: 24 | - uses: actions/checkout@v4 25 | 26 | - name: Set up Python 27 | uses: actions/setup-python@v5 28 | with: 29 | enable-cache: true 30 | python-version: ${{ matrix.python-version }} 31 | 32 | - name: check GITHUB_REF matches package version 33 | uses: samuelcolvin/check-python-version@v4.1 34 | with: 35 | version_file_path: pyproject.toml 36 | 37 | - name: Install Poetry 38 | uses: snok/install-poetry@v1 39 | with: 40 | version: 1.8.2 41 | 42 | - name: Install dependencies 43 | run: poetry install --no-interaction --no-root 44 | 45 | - name: Build release distributions 46 | run: poetry build 47 | 48 | - name: Publish to PyPI 49 | uses: pypa/gh-action-pypi-publish@release/v1 50 | with: 51 | packages-dir: dist/ 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cMCP 2 | 3 | ![cmcp logo](img/cmcp-logo-small.png) 4 | 5 | `cmcp` is a command-line utility that helps you interact with [MCP][1] servers. It's basically `curl` for MCP servers. 6 | 7 | 8 | ## Installation 9 | 10 | ```bash 11 | pip install cmcp 12 | ``` 13 | 14 | 15 | ## Usage 16 | 17 | ### STDIO 18 | 19 | Interact with the STDIO server: 20 | 21 | ```bash 22 | cmcp COMMAND METHOD 23 | ``` 24 | 25 | Add required parameters: 26 | 27 | ```bash 28 | cmcp COMMAND METHOD param1=value param2:='{"arg1": "value"}' 29 | ``` 30 | 31 | Add required environment variables: 32 | 33 | ```bash 34 | cmcp COMMAND METHOD ENV_VAR1:value ENV_VAR2:value param1=value param2:='{"arg1": "value"}' 35 | ``` 36 | 37 | ### Streamable HTTP (or SSE) 38 | 39 | Interact with the Streamable HTTP (or SSE) server: 40 | 41 | ```bash 42 | cmcp URL METHOD 43 | ``` 44 | 45 | Add required parameters: 46 | 47 | ```bash 48 | cmcp URL METHOD param1=value param2:='{"arg1": "value"}' 49 | ``` 50 | 51 | Add required HTTP headers: 52 | 53 | ```bash 54 | cmcp URL METHOD Header1:value Header2:value param1=value param2:='{"arg1": "value"}' 55 | ``` 56 | 57 | ### Verbose mode 58 | 59 | Enable verbose mode to show JSON-RPC request and response: 60 | 61 | ```bash 62 | cmcp -v COMMAND_or_URL METHOD 63 | ``` 64 | 65 | 66 | ## Quick Start 67 | 68 | Given the following MCP Server (see [here][2]): 69 | 70 | ```python 71 | # server.py 72 | from mcp.server.fastmcp import FastMCP 73 | 74 | # Create an MCP server 75 | mcp = FastMCP("Demo") 76 | 77 | 78 | # Add a prompt 79 | @mcp.prompt() 80 | def review_code(code: str) -> str: 81 | return f"Please review this code:\n\n{code}" 82 | 83 | 84 | # Add a static config resource 85 | @mcp.resource("config://app") 86 | def get_config() -> str: 87 | """Static configuration data""" 88 | return "App configuration here" 89 | 90 | 91 | # Add a dynamic greeting resource 92 | @mcp.resource("greeting://{name}") 93 | def get_greeting(name: str) -> str: 94 | """Get a personalized greeting""" 95 | return f"Hello, {name}!" 96 | 97 | 98 | # Add an addition tool 99 | @mcp.tool() 100 | def add(a: int, b: int) -> int: 101 | """Add two numbers""" 102 | return a + b 103 | ``` 104 | 105 | ### STDIO transport 106 | 107 | List prompts: 108 | 109 | ```bash 110 | cmcp 'mcp run server.py' prompts/list 111 | ``` 112 | 113 | Get a prompt: 114 | 115 | ```bash 116 | cmcp 'mcp run server.py' prompts/get name=review_code arguments:='{"code": "def greet(): pass"}' 117 | ``` 118 | 119 | List resources: 120 | 121 | ```bash 122 | cmcp 'mcp run server.py' resources/list 123 | ``` 124 | 125 | Read a resource: 126 | 127 | ```bash 128 | cmcp 'mcp run server.py' resources/read uri=config://app 129 | ``` 130 | 131 | List resource templates: 132 | 133 | ```bash 134 | cmcp 'mcp run server.py' resources/templates/list 135 | ``` 136 | 137 | List tools: 138 | 139 | ```bash 140 | cmcp 'mcp run server.py' tools/list 141 | ``` 142 | 143 | Call a tool: 144 | 145 | ```bash 146 | cmcp 'mcp run server.py' tools/call name=add arguments:='{"a": 1, "b": 2}' 147 | ``` 148 | 149 | ### Streamable HTTP transport 150 | 151 | Run the above MCP server with Streamable HTTP transport: 152 | 153 | ```bash 154 | mcp run server.py -t streamable-http 155 | ``` 156 | 157 | List prompts: 158 | 159 | ```bash 160 | cmcp http://localhost:8000 prompts/list 161 | # or `cmcp http://localhost:8000/mcp prompts/list` 162 | ``` 163 | 164 | Get a prompt: 165 | 166 | ```bash 167 | cmcp http://localhost:8000 prompts/get name=review_code arguments:='{"code": "def greet(): pass"}' 168 | ``` 169 | 170 | List resources: 171 | 172 | ```bash 173 | cmcp http://localhost:8000 resources/list 174 | ``` 175 | 176 | Read a resource: 177 | 178 | ```bash 179 | cmcp http://localhost:8000 resources/read uri=config://app 180 | ``` 181 | 182 | List resource templates: 183 | 184 | ```bash 185 | cmcp http://localhost:8000 resources/templates/list 186 | ``` 187 | 188 | List tools: 189 | 190 | ```bash 191 | cmcp http://localhost:8000 tools/list 192 | ``` 193 | 194 | Call a tool: 195 | 196 | ```bash 197 | cmcp http://localhost:8000 tools/call name=add arguments:='{"a": 1, "b": 2}' 198 | ``` 199 | 200 | ### SSE transport (Deprecated) 201 | 202 | Run the above MCP server with SSE transport: 203 | 204 | ```bash 205 | mcp run server.py -t sse 206 | ``` 207 | 208 | List prompts: 209 | 210 | ```bash 211 | cmcp http://localhost:8000/sse prompts/list 212 | ``` 213 | 214 | Get a prompt: 215 | 216 | ```bash 217 | cmcp http://localhost:8000/sse prompts/get name=review_code arguments:='{"code": "def greet(): pass"}' 218 | ``` 219 | 220 | List resources: 221 | 222 | ```bash 223 | cmcp http://localhost:8000/sse resources/list 224 | ``` 225 | 226 | Read a resource: 227 | 228 | ```bash 229 | cmcp http://localhost:8000/sse resources/read uri=config://app 230 | ``` 231 | 232 | List resource templates: 233 | 234 | ```bash 235 | cmcp http://localhost:8000/sse resources/templates/list 236 | ``` 237 | 238 | List tools: 239 | 240 | ```bash 241 | cmcp http://localhost:8000/sse tools/list 242 | ``` 243 | 244 | Call a tool: 245 | 246 | ```bash 247 | cmcp http://localhost:8000/sse tools/call name=add arguments:='{"a": 1, "b": 2}' 248 | ``` 249 | 250 | 251 | ## Related Projects 252 | 253 | [cA2A][3]: A command-line utility for interacting with A2A agents. 254 | 255 | 256 | ## License 257 | 258 | [MIT][4] 259 | 260 | 261 | [1]: https://modelcontextprotocol.io 262 | [2]: https://github.com/modelcontextprotocol/python-sdk#quickstart 263 | [3]: https://github.com/RussellLuo/ca2a 264 | [4]: http://opensource.org/licenses/MIT 265 | -------------------------------------------------------------------------------- /cmcp.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import asyncio 3 | from contextlib import asynccontextmanager 4 | import json 5 | import os 6 | import re 7 | import shlex 8 | import sys 9 | from typing import Any 10 | from urllib.parse import urljoin 11 | 12 | from mcp import ClientSession, StdioServerParameters 13 | from mcp.client.sse import sse_client 14 | from mcp.client.stdio import stdio_client 15 | from mcp.client.streamable_http import streamablehttp_client 16 | from mcp.types import JSONRPCRequest, JSONRPCResponse, Result 17 | from pydantic import BaseModel 18 | from pygments import highlight 19 | from pygments.lexers import JsonLexer 20 | from pygments.formatters import TerminalFormatter 21 | 22 | 23 | METHODS = ( 24 | "prompts/list", 25 | "prompts/get", 26 | "resources/list", 27 | "resources/read", 28 | "resources/templates/list", 29 | "tools/list", 30 | "tools/call", 31 | ) 32 | 33 | 34 | @asynccontextmanager 35 | async def simplified_streamablehttp_client(*args, **kwargs): 36 | """Simplified version of streamablehttp_client(), which only returns (read, write) tuple. 37 | 38 | Usage example: 39 | async with simplified_streamablehttp_client(...) as (read, write): 40 | ... 41 | """ 42 | async with streamablehttp_client(*args, **kwargs) as (read, write, _): 43 | yield (read, write) 44 | 45 | 46 | class Client(BaseModel): 47 | cmd_or_url: str 48 | method: str 49 | params: dict[str, Any] 50 | 51 | metadata: dict[str, str] 52 | """Additional metadata. 53 | 54 | STDIO transport: 55 | - The key/value pairs are passed as environment variables to the server. 56 | 57 | SSE transport: 58 | - The key/value pairs are passed as HTTP headers to the server. 59 | """ 60 | 61 | async def invoke(self, verbose: bool) -> Result: 62 | if self.cmd_or_url.startswith(("http://", "https://")): 63 | url = self.cmd_or_url 64 | headers = self.metadata or None 65 | if url.endswith("/sse"): 66 | # Explicitly specified SSE transport. 67 | client = sse_client(url=url, headers=headers) 68 | else: 69 | # Default to Streamable HTTP transport. 70 | if not url.endswith("/mcp"): 71 | url = url.removesuffix("/") + "/mcp" 72 | client = simplified_streamablehttp_client(url=url, headers=headers) 73 | else: 74 | # STDIO transport 75 | elements = shlex.split(self.cmd_or_url) 76 | if not elements: 77 | raise ValueError("stdio command is empty") 78 | 79 | command, args = elements[0], elements[1:] 80 | server_params = StdioServerParameters( 81 | command=command, 82 | args=args, 83 | env=self.metadata or None, 84 | ) 85 | client = stdio_client(server_params) 86 | 87 | async with client as (read, write): 88 | async with ClientSession(read, write) as session: 89 | await session.initialize() 90 | 91 | if verbose: 92 | self.show_jsonrpc_request() 93 | 94 | match self.method: 95 | case "prompts/list": 96 | result = await session.list_prompts() 97 | 98 | case "prompts/get": 99 | result = await session.get_prompt(**self.params) 100 | 101 | case "resources/list": 102 | result = await session.list_resources() 103 | 104 | case "resources/read": 105 | result = await session.read_resource(**self.params) 106 | 107 | case "resources/templates/list": 108 | result = await session.list_resource_templates() 109 | 110 | case "tools/list": 111 | result = await session.list_tools() 112 | 113 | case "tools/call": 114 | result = await session.call_tool(**self.params) 115 | 116 | case _: 117 | raise ValueError(f"Unsupported method: {self.method}") 118 | 119 | if verbose: 120 | self.show_jsonrpc_response(result) 121 | else: 122 | print_json(result) 123 | 124 | return result 125 | 126 | def show_jsonrpc_request(self) -> None: 127 | print("Request:") 128 | print_json( 129 | JSONRPCRequest( 130 | jsonrpc="2.0", 131 | id=1, 132 | method=self.method, 133 | params=self.params or None, 134 | ) 135 | ) 136 | 137 | def show_jsonrpc_response(self, result: Result) -> None: 138 | print("Response:") 139 | print_json( 140 | JSONRPCResponse( 141 | jsonrpc="2.0", 142 | id=1, 143 | result=result.model_dump(exclude_defaults=True), 144 | ) 145 | ) 146 | 147 | 148 | def print_json(result: BaseModel) -> None: 149 | """Print the given result object with syntax highlighting.""" 150 | json_str = result.model_dump_json(indent=2, exclude_defaults=True) 151 | if not sys.stdout.isatty(): 152 | print(json_str) 153 | else: 154 | highlighted = highlight(json_str, JsonLexer(), TerminalFormatter()) 155 | print(highlighted) 156 | 157 | 158 | def parse_items(items: list[str]) -> tuple[dict[str, Any], dict[str, str]]: 159 | """Parse items in the form of `key:value`, `key=string_value` or `key:=json_value`.""" 160 | 161 | # Regular expression pattern 162 | PATTERN = re.compile(r"^([^:=]+)(=|:=|:)(.+)$", re.DOTALL) 163 | 164 | params: dict[str, Any] = {} 165 | metadata: dict[str, str] = {} 166 | 167 | def parse(item: str) -> None: 168 | match = PATTERN.match(item) 169 | if not match: 170 | raise ValueError(f"Invalid item: {item!r}") 171 | 172 | key, separator, value = match.groups() 173 | match separator: 174 | case "=": # String field 175 | params[key] = value 176 | case ":=": # Raw JSON field 177 | try: 178 | parsed_value = json.loads(value) 179 | params[key] = parsed_value 180 | except json.JSONDecodeError: 181 | raise ValueError(f"Invalid JSON value: {value!r}") 182 | case ":": # Metadata 183 | metadata[key] = value 184 | case _: 185 | raise ValueError(f"Unsupported separator: {separator!r}") 186 | 187 | for item in items: 188 | parse(item) 189 | 190 | return params, metadata 191 | 192 | 193 | def main() -> None: 194 | parser = argparse.ArgumentParser( 195 | description="A command-line utility for interacting with MCP servers." 196 | ) 197 | parser.add_argument( 198 | "cmd_or_url", 199 | help="The command (stdio-transport) or URL (sse-transport) to connect to the MCP server", 200 | ) 201 | parser.add_argument("method", help="The method to be invoked") 202 | parser.add_argument( 203 | "items", 204 | nargs="*", 205 | help="""\ 206 | The parameter values (in the form of `key=string_value` or `key:=json_value`), 207 | or the metadata values (in the form of `key:value`)\ 208 | """, 209 | ) 210 | parser.add_argument( 211 | "-v", 212 | "--verbose", 213 | action="store_true", 214 | help="Enable verbose output showing JSON-RPC request/response", 215 | ) 216 | args = parser.parse_args() 217 | 218 | if args.method not in METHODS: 219 | parser.error( 220 | f"Invalid method: {args.method} (choose from {', '.join(METHODS)})." 221 | ) 222 | 223 | try: 224 | params, metadata = parse_items(args.items) 225 | except ValueError as exc: 226 | parser.error(str(exc)) 227 | 228 | client = Client( 229 | cmd_or_url=args.cmd_or_url, 230 | method=args.method, 231 | params=params, 232 | metadata=metadata, 233 | ) 234 | asyncio.run(client.invoke(args.verbose)) 235 | 236 | 237 | if __name__ == "__main__": 238 | main() 239 | --------------------------------------------------------------------------------