mcp-curl 0.2.1__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mike D
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,383 @@
1
+ Metadata-Version: 2.2
2
+ Name: mcp-curl
3
+ Version: 0.2.1
4
+ Summary: A curl-like CLI tool for interacting with Model Context Protocol (MCP) servers
5
+ Author: turlockmike
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/turlockmike/murl
8
+ Project-URL: Repository, https://github.com/turlockmike/murl
9
+ Project-URL: Issues, https://github.com/turlockmike/murl/issues
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: click>=8.0.0
22
+ Requires-Dist: mcp>=1.0.0
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
25
+ Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
26
+ Requires-Dist: requests>=2.31.0; extra == "dev"
27
+
28
+ # murl - MCP Curl
29
+
30
+ [![Tests](https://github.com/turlockmike/murl/actions/workflows/test.yml/badge.svg)](https://github.com/turlockmike/murl/actions/workflows/test.yml)
31
+
32
+ A curl-like CLI tool for interacting with Model Context Protocol (MCP) servers.
33
+
34
+ ## What is MCP?
35
+
36
+ MCP (Model Context Protocol) is an open standard developed by Anthropic for AI models to access external data sources, tools, and services. It provides a universal way for large language models (LLMs) to interact with various resources securely and efficiently.
37
+
38
+ ## Quick Start
39
+
40
+ ### Option 1: Try with Public MCP Servers (No Setup Required)
41
+
42
+ Test murl immediately with public MCP servers:
43
+
44
+ ```bash
45
+ # Install murl
46
+ curl -sSL https://raw.githubusercontent.com/turlockmike/murl/master/install.sh | bash
47
+ # Or using pip: pip install mcp-curl
48
+
49
+ # List available tools on the echo server
50
+ murl https://echo.mcp.inevitable.fyi/mcp/tools
51
+
52
+ # Call the echo tool with a message
53
+ murl https://echo.mcp.inevitable.fyi/mcp/tools/echo -d message="Hello from murl!"
54
+
55
+ # Try the everything server with multiple tools
56
+ murl https://everything.mcp.inevitable.fyi/mcp/tools
57
+
58
+ # Get current time from the time server
59
+ murl https://time.mcp.inevitable.fyi/mcp/tools/get_current_time
60
+ ```
61
+
62
+ **Available public test servers:**
63
+ - Echo Server: `https://echo.mcp.inevitable.fyi/mcp` - Simple echo tool for testing
64
+ - Everything Server: `https://everything.mcp.inevitable.fyi/mcp` - Multipurpose server with various capabilities
65
+ - Time Server: `https://time.mcp.inevitable.fyi/mcp` - Time-related tools
66
+
67
+ ### Option 2: Quick Local Setup with mcp-proxy
68
+
69
+ For testing local or stdio MCP servers:
70
+
71
+ ```bash
72
+ # Install murl and mcp-proxy
73
+ curl -sSL https://raw.githubusercontent.com/turlockmike/murl/master/install.sh | bash
74
+ pip install mcp-proxy
75
+ # Or install murl with pip: pip install mcp-curl mcp-proxy
76
+
77
+ # Start a simple time server example
78
+ # (You'll need to have an MCP server to proxy - see the full documentation below)
79
+ mcp-proxy --port 3000 uvx mcp-server-time
80
+
81
+ # In another terminal, test with murl
82
+ murl http://localhost:3000/tools
83
+ murl http://localhost:3000/tools/get_current_time
84
+ ```
85
+
86
+ ## Installation
87
+
88
+ ### Quick Install (Recommended)
89
+
90
+ Install murl with a single command:
91
+
92
+ ```bash
93
+ curl -sSL https://raw.githubusercontent.com/turlockmike/murl/master/install.sh | bash
94
+ ```
95
+
96
+ This will automatically download and install murl from source.
97
+
98
+ ### Using pip
99
+
100
+ Install murl from PyPI:
101
+
102
+ ```bash
103
+ pip install mcp-curl
104
+ ```
105
+
106
+ To upgrade to the latest version:
107
+
108
+ ```bash
109
+ pip install --upgrade mcp-curl
110
+ ```
111
+
112
+ ### Upgrade
113
+
114
+ To upgrade murl to the latest version:
115
+
116
+ ```bash
117
+ murl --upgrade
118
+ ```
119
+
120
+ This command downloads and runs the installation script to update murl to the latest release from GitHub.
121
+
122
+ ### From Source
123
+
124
+ ```bash
125
+ git clone https://github.com/turlockmike/murl.git
126
+ cd murl
127
+ pip install -e .
128
+ ```
129
+
130
+ ## Usage
131
+
132
+ `murl` provides a curl-like interface for interacting with MCP servers over HTTP. It abstracts the JSON-RPC 2.0 protocol, making it easy to call MCP methods using intuitive REST-like paths.
133
+
134
+ ### Basic Syntax
135
+
136
+ ```bash
137
+ murl <url> [options]
138
+ ```
139
+
140
+ Where `<url>` is the MCP server endpoint with a virtual path (e.g., `http://localhost:3000/tools`).
141
+
142
+ ### Options
143
+
144
+ - `-d, --data <key=value>` - Add data to the request. Can be used multiple times.
145
+ - `-H, --header <key: value>` - Add custom HTTP headers (e.g., for authentication).
146
+ - `-v, --verbose` - Enable verbose output (prints request/response details to stderr).
147
+ - `--version` - Show detailed version information (includes Python version and installation path).
148
+ - `--upgrade` - Upgrade murl to the latest version from GitHub releases.
149
+ - `--help` - Show help message.
150
+
151
+ ### Examples
152
+
153
+ #### List Available Tools
154
+
155
+ ```bash
156
+ murl http://localhost:3000/tools
157
+ ```
158
+
159
+ This sends a `tools/list` request to the MCP server.
160
+
161
+ #### Call a Tool with Arguments
162
+
163
+ ```bash
164
+ murl http://localhost:3000/tools/echo -d message=hello
165
+ ```
166
+
167
+ This sends a `tools/call` request with the tool name "echo" and arguments `{"message": "hello"}`.
168
+
169
+ #### Call a Tool with Multiple Arguments
170
+
171
+ ```bash
172
+ murl http://localhost:3000/tools/weather -d city=Paris -d metric=true
173
+ ```
174
+
175
+ Arguments are automatically type-coerced (strings, numbers, booleans).
176
+
177
+ #### Call a Tool with JSON Data
178
+
179
+ ```bash
180
+ murl http://localhost:3000/tools/config -d '{"settings": {"theme": "dark"}}'
181
+ ```
182
+
183
+ You can pass complex JSON objects directly.
184
+
185
+ #### List Available Resources
186
+
187
+ ```bash
188
+ murl http://localhost:3000/resources
189
+ ```
190
+
191
+ This sends a `resources/list` request.
192
+
193
+ #### Read a Resource
194
+
195
+ ```bash
196
+ murl http://localhost:3000/resources/path/to/file
197
+ ```
198
+
199
+ This sends a `resources/read` request with the file path. The path is automatically converted to a `file://` URI.
200
+
201
+ #### List Available Prompts
202
+
203
+ ```bash
204
+ murl http://localhost:3000/prompts
205
+ ```
206
+
207
+ This sends a `prompts/list` request.
208
+
209
+ #### Get a Prompt
210
+
211
+ ```bash
212
+ murl http://localhost:3000/prompts/greeting -d name=Alice
213
+ ```
214
+
215
+ This sends a `prompts/get` request with the prompt name "greeting" and arguments.
216
+
217
+ #### Add Authorization Headers
218
+
219
+ ```bash
220
+ murl http://localhost:3000/tools -H "Authorization: Bearer token123"
221
+ ```
222
+
223
+ Custom headers can be added for authentication or other purposes.
224
+
225
+ #### Verbose Mode
226
+
227
+ ```bash
228
+ murl http://localhost:3000/tools -v
229
+ ```
230
+
231
+ Verbose mode prints the JSON-RPC request payload and HTTP headers to stderr, useful for debugging.
232
+
233
+ ### URL Mapping
234
+
235
+ `murl` automatically maps REST-like paths to MCP JSON-RPC methods:
236
+
237
+ | URL Path | MCP Method | Parameters |
238
+ | ------------------------- | ----------------- | ----------------------------------------- |
239
+ | `/tools` | `tools/list` | `{}` |
240
+ | `/tools/<name>` | `tools/call` | `{name: "<name>", arguments: {...}}` |
241
+ | `/resources` | `resources/list` | `{}` |
242
+ | `/resources/<path>` | `resources/read` | `{uri: "file:///<path>"}` (three slashes) |
243
+ | `/prompts` | `prompts/list` | `{}` |
244
+ | `/prompts/<name>` | `prompts/get` | `{name: "<name>", arguments: {...}}` |
245
+
246
+ ### Piping Output
247
+
248
+ `murl` outputs raw JSON to stdout, making it pipe-friendly:
249
+
250
+ ```bash
251
+ # Use with jq to format output
252
+ murl http://localhost:3000/tools | jq .
253
+
254
+ # Extract specific fields
255
+ murl http://localhost:3000/tools | jq '.[0].name'
256
+ ```
257
+
258
+ ## Development
259
+
260
+ ### Setup Development Environment
261
+
262
+ ```bash
263
+ # Clone the repository
264
+ git clone https://github.com/turlockmike/murl.git
265
+ cd murl
266
+
267
+ # Install in development mode with dev dependencies
268
+ pip install -e ".[dev]"
269
+ ```
270
+
271
+ ### Running Tests
272
+
273
+ ```bash
274
+ pytest
275
+ ```
276
+
277
+ ### Running Tests with Coverage
278
+
279
+ ```bash
280
+ pytest --cov=murl --cov-report=html
281
+ ```
282
+
283
+ ## How It Works
284
+
285
+ `murl` works by:
286
+
287
+ 1. **Parsing the URL** to extract the base endpoint and the MCP virtual path
288
+ 2. **Mapping the virtual path** to the appropriate MCP JSON-RPC method
289
+ 3. **Parsing data flags** (`-d`) into method parameters with type coercion
290
+ 4. **Constructing a JSON-RPC 2.0 request** with the method and parameters
291
+ 5. **Sending an HTTP POST request** to the base endpoint with the JSON-RPC payload
292
+ 6. **Extracting the result** from the JSON-RPC response and printing it as JSON
293
+
294
+ ## Using murl with MCP Servers
295
+
296
+ `murl` supports the Streamable HTTP transport protocol used by modern MCP servers. This allows murl to work with MCP servers that implement HTTP-based transport.
297
+
298
+ ### Streamable HTTP Support
299
+
300
+ As of the latest version, murl includes comprehensive support for the MCP Streamable HTTP transport protocol:
301
+ - Sends `Accept: application/json, text/event-stream` header
302
+ - Handles both immediate JSON responses and Server-Sent Events (SSE) streams
303
+ - **Supports session-based SSE** for compatibility with mcp-proxy
304
+ - Automatically tries session-based SSE first, then falls back to regular HTTP POST
305
+ - Compatible with MCP servers implementing the Streamable HTTP specification
306
+
307
+ ### Direct HTTP MCP Servers
308
+
309
+ murl works best with MCP servers that expose a direct HTTP JSON-RPC endpoint. For example, if you have a server running at `http://localhost:3000` that implements MCP over HTTP:
310
+
311
+ ```bash
312
+ # List available tools
313
+ murl http://localhost:3000/tools
314
+
315
+ # Call a tool with arguments
316
+ murl http://localhost:3000/tools/my_tool -d param1=value1 -d param2=value2
317
+
318
+ # List resources
319
+ murl http://localhost:3000/resources
320
+
321
+ # Get a prompt
322
+ murl http://localhost:3000/prompts/my_prompt -d arg1=value
323
+ ```
324
+
325
+ ### Using murl with mcp-proxy
326
+
327
+ Many MCP servers are implemented as stdio (standard input/output) programs. To use these with murl, you can expose them via HTTP using [mcp-proxy](https://github.com/sparfenyuk/mcp-proxy):
328
+
329
+ ```bash
330
+ # Install mcp-proxy
331
+ pip install mcp-proxy
332
+
333
+ # Start mcp-proxy to expose a stdio MCP server on HTTP port 3000
334
+ mcp-proxy --port 3000 python my_mcp_server.py
335
+
336
+ # Or for a Node.js MCP server
337
+ mcp-proxy --port 3000 node path/to/mcp-server.js
338
+ ```
339
+
340
+ Once mcp-proxy is running, you can use murl to interact with your stdio MCP server:
341
+
342
+ ```bash
343
+ # List available tools
344
+ murl http://localhost:3000/tools
345
+
346
+ # Call a tool with arguments
347
+ murl http://localhost:3000/tools/my_tool -d param1=value1 -d param2=value2
348
+
349
+ # List resources
350
+ murl http://localhost:3000/resources
351
+
352
+ # Get a prompt
353
+ murl http://localhost:3000/prompts/my_prompt -d arg1=value
354
+ ```
355
+
356
+ **How it works**: murl automatically detects mcp-proxy's session-based SSE architecture and handles it transparently:
357
+ 1. Connects to the SSE endpoint to get a session ID
358
+ 2. Posts the request to the session-specific endpoint
359
+ 3. Reads the response from the SSE stream
360
+ 4. Each murl invocation creates and closes its own ephemeral session
361
+
362
+ For more information about MCP transport protocols, see the [official MCP documentation](https://modelcontextprotocol.io/specification/basic/transports).
363
+
364
+ ## Requirements
365
+
366
+ - Python 3.10 or higher
367
+ - `click` - For CLI argument parsing
368
+ - `requests` - For HTTP requests
369
+
370
+ ## License
371
+
372
+ MIT License - see LICENSE file for details
373
+
374
+ ## Releasing
375
+
376
+ For maintainers: To create a new release, update the version in `pyproject.toml` and `murl/__init__.py`, then create and push a git tag:
377
+
378
+ ```bash
379
+ git tag v0.2.1
380
+ git push origin v0.2.1
381
+ ```
382
+
383
+ This will automatically trigger a GitHub Actions workflow that builds the package and creates a GitHub release with the artifacts.
@@ -0,0 +1,8 @@
1
+ murl/__init__.py,sha256=3Ctj8PNExLK0RkJvp_TsAXDPVyKmClhDh2mIhwL2VV4,99
2
+ murl/cli.py,sha256=E653cZyPoa5cALlnO4Dt4Tx4ctfbYBBudiYb11iH1t8,17179
3
+ mcp_curl-0.2.1.dist-info/LICENSE,sha256=zERRs8wBVYS6pPsaWrEnZ5_6Glito6nb4sIwBj7hIC4,1063
4
+ mcp_curl-0.2.1.dist-info/METADATA,sha256=NT19GfJNNjVRxnJxT_DDfQ4vm7wYeq4Vx4aYxS0Lems,11329
5
+ mcp_curl-0.2.1.dist-info/WHEEL,sha256=beeZ86-EfXScwlR_HKu4SllMC9wUEj_8Z_4FJ3egI2w,91
6
+ mcp_curl-0.2.1.dist-info/entry_points.txt,sha256=8VX69QOGt5SkSca_j9vr209QE9C8uBz5-27rUNSDOU8,39
7
+ mcp_curl-0.2.1.dist-info/top_level.txt,sha256=cmvxbiwOKJ2254P4OYZ8xkVTI7h7H1aorcFBVB4GoB0,5
8
+ mcp_curl-0.2.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (76.1.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ murl = murl.cli:main
@@ -0,0 +1 @@
1
+ murl
murl/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """murl - A curl-like CLI tool for Model Context Protocol (MCP) servers."""
2
+
3
+ __version__ = "0.2.1"
murl/cli.py ADDED
@@ -0,0 +1,471 @@
1
+ """CLI entry point for murl."""
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ import re
7
+ import subprocess
8
+ import sys
9
+ from typing import Dict, Any, Tuple, Optional
10
+
11
+ import click
12
+ from mcp import ClientSession
13
+ from mcp.client.streamable_http import streamable_http_client
14
+ from murl import __version__
15
+
16
+
17
+ def parse_url(full_url: str) -> Tuple[str, str]:
18
+ """Parse the full URL into base URL and virtual path.
19
+
20
+ Args:
21
+ full_url: The complete URL including the MCP path
22
+
23
+ Returns:
24
+ Tuple of (base_url, virtual_path)
25
+
26
+ Raises:
27
+ ValueError: If the URL doesn't contain a valid MCP path
28
+ """
29
+ # Regex to find the start of MCP segments
30
+ pattern = r'/(tools|resources|prompts)(\/.*)?$'
31
+ match = re.search(pattern, full_url)
32
+
33
+ if not match:
34
+ raise ValueError(
35
+ "Invalid MCP URL. Must contain /tools, /resources, or /prompts"
36
+ )
37
+
38
+ virtual_path = match.group(0) # e.g., "/tools/weather"
39
+ base_url = full_url[:match.start()] # e.g., "https://api.com/mcp"
40
+
41
+ return base_url, virtual_path
42
+
43
+
44
+ def parse_data_value(value: str) -> Any:
45
+ """Parse a data value and coerce types.
46
+
47
+ Args:
48
+ value: The string value to parse
49
+
50
+ Returns:
51
+ The parsed value (bool, int, float, or string)
52
+ """
53
+ # Try to parse as boolean
54
+ if value.lower() == 'true':
55
+ return True
56
+ if value.lower() == 'false':
57
+ return False
58
+
59
+ # Try to parse as number
60
+ try:
61
+ # Try integer first
62
+ if '.' not in value:
63
+ return int(value)
64
+ # Try float
65
+ return float(value)
66
+ except ValueError:
67
+ pass
68
+
69
+ # Return as string
70
+ return value
71
+
72
+
73
+ def parse_data_flags(data_flags: Tuple[str, ...]) -> Dict[str, Any]:
74
+ """Parse -d/--data flags into a dictionary.
75
+
76
+ Args:
77
+ data_flags: Tuple of data flag values
78
+
79
+ Returns:
80
+ Dictionary of parsed key-value pairs
81
+
82
+ Note:
83
+ JSON objects (starting with '{') are merged into the result.
84
+ JSON arrays (starting with '[') are not supported as they don't
85
+ represent key-value pairs needed for MCP arguments.
86
+ """
87
+ result = {}
88
+
89
+ for data in data_flags:
90
+ # Check if it's a JSON object
91
+ stripped = data.strip()
92
+ if stripped.startswith('{'):
93
+ try:
94
+ parsed = json.loads(data)
95
+ if not isinstance(parsed, dict):
96
+ raise ValueError(f"JSON in -d flag must be an object, not {type(parsed).__name__}")
97
+ result.update(parsed)
98
+ except json.JSONDecodeError:
99
+ raise ValueError(f"Invalid JSON in -d flag: {data}")
100
+ elif stripped.startswith('['):
101
+ raise ValueError(f"JSON arrays are not supported in -d flag. Use key=value or JSON objects.")
102
+ else:
103
+ # Parse key=value format
104
+ if '=' not in data:
105
+ raise ValueError(f"Invalid data format: {data}. Expected key=value or JSON")
106
+
107
+ key, value = data.split('=', 1)
108
+ result[key] = parse_data_value(value)
109
+
110
+ return result
111
+
112
+
113
+ def map_virtual_path_to_method(virtual_path: str, data: Dict[str, Any]) -> Tuple[str, Dict[str, Any]]:
114
+ """Map a virtual path to an MCP JSON-RPC method and params.
115
+
116
+ Args:
117
+ virtual_path: The MCP path (e.g., "/tools", "/tools/weather")
118
+ data: The parsed data dictionary
119
+
120
+ Returns:
121
+ Tuple of (method, params)
122
+ """
123
+ # Remove leading slash and split
124
+ parts = virtual_path.lstrip('/').split('/')
125
+
126
+ # Check for empty path
127
+ if not parts or parts == ['']:
128
+ raise ValueError("Invalid virtual path: empty path")
129
+
130
+ category = parts[0] # tools, resources, or prompts
131
+
132
+ if category == 'tools':
133
+ if len(parts) == 1:
134
+ # /tools -> tools/list
135
+ return 'tools/list', {}
136
+ else:
137
+ # /tools/<name> -> tools/call
138
+ tool_name = parts[1]
139
+ return 'tools/call', {
140
+ 'name': tool_name,
141
+ 'arguments': data
142
+ }
143
+
144
+ elif category == 'resources':
145
+ if len(parts) == 1:
146
+ # /resources -> resources/list
147
+ return 'resources/list', {}
148
+ else:
149
+ # /resources/<path> -> resources/read with file:// URI
150
+ # Join all parts after 'resources' to form the file path
151
+ # Prepend '/' to make it an absolute path: /resources/path/to/file -> file:///path/to/file
152
+ file_path = '/'.join(parts[1:])
153
+ # Handle empty path case (e.g., /resources/ with trailing slash)
154
+ if not file_path or file_path == '':
155
+ raise ValueError("Invalid resources path: path cannot be empty after /resources/")
156
+ # Ensure absolute path starts with '/'
157
+ if not file_path.startswith('/'):
158
+ file_path = '/' + file_path
159
+ uri = f'file://{file_path}'
160
+ # Merge with any additional data parameters passed via -d flags
161
+ return 'resources/read', {'uri': uri, **data}
162
+
163
+ elif category == 'prompts':
164
+ if len(parts) == 1:
165
+ # /prompts -> prompts/list
166
+ return 'prompts/list', {}
167
+ else:
168
+ # /prompts/<name> -> prompts/get
169
+ prompt_name = parts[1]
170
+ return 'prompts/get', {
171
+ 'name': prompt_name,
172
+ 'arguments': data
173
+ }
174
+
175
+ else:
176
+ raise ValueError(f"Invalid MCP category: {category}")
177
+
178
+
179
+ def parse_headers(header_flags: Tuple[str, ...]) -> Dict[str, str]:
180
+ """Parse -H/--header flags into a dictionary.
181
+
182
+ Args:
183
+ header_flags: Tuple of header flag values
184
+
185
+ Returns:
186
+ Dictionary of headers
187
+ """
188
+ headers = {}
189
+
190
+ for header in header_flags:
191
+ if ':' not in header:
192
+ raise ValueError(f"Invalid header format: {header}. Expected 'Key: Value'")
193
+
194
+ key, value = header.split(':', 1)
195
+ headers[key.strip()] = value.strip()
196
+
197
+ return headers
198
+
199
+
200
+ async def make_mcp_request(
201
+ base_url: str,
202
+ method: str,
203
+ params: Dict[str, Any],
204
+ headers: Dict[str, str],
205
+ verbose: bool
206
+ ) -> Any:
207
+ """Make an MCP request using the official SDK.
208
+
209
+ Args:
210
+ base_url: The base URL of the MCP server
211
+ method: The MCP method to call
212
+ params: The method parameters
213
+ headers: Custom HTTP headers to include in requests
214
+ verbose: Whether to print verbose output
215
+
216
+ Returns:
217
+ The result from the MCP server (as JSON-serializable data)
218
+
219
+ Raises:
220
+ Exception: If the request fails
221
+ """
222
+ # Validate required parameters before making connection
223
+ if method == 'tools/call':
224
+ if params.get('name') is None:
225
+ raise ValueError("Missing required 'name' parameter for tools/call method")
226
+ elif method == 'resources/read':
227
+ if params.get('uri') is None:
228
+ raise ValueError("Missing required 'uri' parameter for resources/read method")
229
+ elif method == 'prompts/get':
230
+ if params.get('name') is None:
231
+ raise ValueError("Missing required 'name' parameter for prompts/get request")
232
+
233
+ if verbose:
234
+ click.echo("=== MCP Request ===", err=True)
235
+ click.echo(f"Method: {method}", err=True)
236
+ click.echo(f"Params: {json.dumps(params, indent=2)}", err=True)
237
+ click.echo(f"URL: {base_url}", err=True)
238
+ if headers:
239
+ click.echo(f"Headers: {json.dumps(headers, indent=2)}", err=True)
240
+ click.echo("", err=True)
241
+
242
+ # Create httpx client with custom headers if provided
243
+ import httpx
244
+ http_client = None
245
+ if headers:
246
+ http_client = httpx.AsyncClient(headers=headers)
247
+
248
+ try:
249
+ async with streamable_http_client(base_url, http_client=http_client) as (read, write, get_session_id):
250
+ async with ClientSession(read, write) as session:
251
+ # Initialize the session
252
+ init_result = await session.initialize()
253
+
254
+ if verbose:
255
+ click.echo("=== MCP Initialization ===", err=True)
256
+ click.echo(f"Protocol Version: {init_result.protocolVersion}", err=True)
257
+ click.echo(f"Server: {init_result.serverInfo.name} {init_result.serverInfo.version}", err=True)
258
+ click.echo("", err=True)
259
+
260
+ # Route to appropriate SDK method
261
+ if method == 'tools/list':
262
+ result = await session.list_tools()
263
+ # Convert pydantic models to dict
264
+ return [tool.model_dump(mode='json', exclude_none=True) for tool in result.tools]
265
+ elif method == 'tools/call':
266
+ tool_name = params.get('name')
267
+ arguments = params.get('arguments', {})
268
+ result = await session.call_tool(tool_name, arguments)
269
+ # Convert content to dict
270
+ return [content.model_dump(mode='json', exclude_none=True) for content in result.content]
271
+ elif method == 'resources/list':
272
+ result = await session.list_resources()
273
+ # Convert resources to dict
274
+ return [resource.model_dump(mode='json', exclude_none=True) for resource in result.resources]
275
+ elif method == 'resources/read':
276
+ uri = params.get('uri')
277
+ result = await session.read_resource(uri)
278
+ # Convert contents to dict
279
+ return [content.model_dump(mode='json', exclude_none=True) for content in result.contents]
280
+ elif method == 'prompts/list':
281
+ result = await session.list_prompts()
282
+ # Convert prompts to dict
283
+ return [prompt.model_dump(mode='json', exclude_none=True) for prompt in result.prompts]
284
+ elif method == 'prompts/get':
285
+ prompt_name = params.get('name')
286
+ arguments = params.get('arguments', {})
287
+ result = await session.get_prompt(prompt_name, arguments)
288
+ # Convert messages to dict
289
+ return [message.model_dump(mode='json', exclude_none=True) for message in result.messages]
290
+ else:
291
+ raise ValueError(f"Unsupported method: {method}")
292
+ finally:
293
+ # Clean up the http_client if we created one
294
+ if http_client is not None:
295
+ await http_client.aclose()
296
+
297
+
298
+
299
+ def print_version(ctx, param, value):
300
+ """Print detailed version information."""
301
+ if not value or ctx.resilient_parsing:
302
+ return
303
+
304
+ # Get Python version
305
+ python_version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
306
+
307
+ # Try to get installation path
308
+ try:
309
+ import murl
310
+ install_path = os.path.dirname(os.path.abspath(murl.__file__))
311
+ except Exception:
312
+ install_path = "unknown"
313
+
314
+ click.echo(f"murl version {__version__}")
315
+ click.echo(f"Python {python_version}")
316
+ click.echo(f"Installation path: {install_path}")
317
+ ctx.exit()
318
+
319
+
320
+ def run_upgrade(ctx, param, value):
321
+ """Run the upgrade process."""
322
+ if not value or ctx.resilient_parsing:
323
+ return
324
+
325
+ # Repository configuration - using master branch as primary
326
+ github_repo_url = "https://raw.githubusercontent.com/turlockmike/murl/master/install.sh"
327
+
328
+ try:
329
+ # Check if required tools are available
330
+ try:
331
+ subprocess.run(["curl", "--version"], capture_output=True, check=True)
332
+ except (subprocess.CalledProcessError, FileNotFoundError):
333
+ click.echo("Error: curl is not installed. Please install curl and try again.", err=True)
334
+ ctx.exit(1)
335
+
336
+ try:
337
+ subprocess.run(["bash", "--version"], capture_output=True, check=True)
338
+ except (subprocess.CalledProcessError, FileNotFoundError):
339
+ click.echo("Error: bash is not installed. Please install bash and try again.", err=True)
340
+ ctx.exit(1)
341
+
342
+ click.echo("Upgrading murl...")
343
+ click.echo("Downloading and running install script...")
344
+
345
+ # Use subprocess with list arguments to avoid shell injection
346
+ # First download the script
347
+ download_result = subprocess.run(
348
+ ["curl", "-sSL", github_repo_url],
349
+ capture_output=True,
350
+ text=True,
351
+ check=False
352
+ )
353
+
354
+ if download_result.returncode != 0:
355
+ click.echo(f"Error: Failed to download install script: {download_result.stderr}", err=True)
356
+ ctx.exit(1)
357
+
358
+ # Then execute it with bash
359
+ result = subprocess.run(
360
+ ["bash"],
361
+ input=download_result.stdout,
362
+ capture_output=True,
363
+ text=True,
364
+ check=False
365
+ )
366
+
367
+ if result.returncode == 0:
368
+ click.echo(result.stdout)
369
+ click.echo("✓ Upgrade complete!")
370
+ ctx.exit(0)
371
+ else:
372
+ click.echo(f"Error during upgrade: {result.stderr}", err=True)
373
+ ctx.exit(1)
374
+
375
+ except subprocess.SubprocessError as e:
376
+ click.echo(f"Error: Subprocess failed: {e}", err=True)
377
+ ctx.exit(1)
378
+ except PermissionError as e:
379
+ click.echo(f"Error: Permission denied: {e}. You may need elevated privileges.", err=True)
380
+ ctx.exit(1)
381
+ except Exception as e:
382
+ click.echo(f"Error: Unexpected error during upgrade: {e}", err=True)
383
+ ctx.exit(1)
384
+
385
+
386
+ @click.command()
387
+ @click.argument('url', required=False)
388
+ @click.option('-d', '--data', 'data_flags', multiple=True,
389
+ help='Add data to the request. Format: key=value or JSON string')
390
+ @click.option('-H', '--header', 'header_flags', multiple=True,
391
+ help='Add custom HTTP header. Format: "Key: Value"')
392
+ @click.option('-v', '--verbose', is_flag=True,
393
+ help='Enable verbose output (prints JSON-RPC payload and HTTP headers to stderr)')
394
+ @click.option('--version', is_flag=True, callback=print_version, expose_value=False, is_eager=True,
395
+ help='Show detailed version information')
396
+ @click.option('--upgrade', is_flag=True, callback=run_upgrade, expose_value=False, is_eager=True,
397
+ help='Upgrade murl to the latest version')
398
+ def main(url: Optional[str], data_flags: Tuple[str, ...], header_flags: Tuple[str, ...], verbose: bool):
399
+ """murl - MCP Curl: A curl-like CLI tool for Model Context Protocol (MCP) servers.
400
+
401
+ MCP (Model Context Protocol) is an open standard for AI models to access
402
+ external data sources and tools. murl provides a command-line interface
403
+ to interact with MCP servers.
404
+
405
+ Examples:
406
+
407
+ # List tools
408
+ murl http://localhost:3000/tools
409
+
410
+ # Call a tool
411
+ murl http://localhost:3000/tools/echo -d message=hello
412
+
413
+ # Call a tool with JSON data
414
+ murl http://localhost:3000/tools/config -d '{"theme": "dark"}'
415
+
416
+ # Read a resource (file path)
417
+ murl http://localhost:3000/resources/path/to/file
418
+
419
+ # Add authorization header
420
+ murl http://localhost:3000/prompts -H "Authorization: Bearer token123"
421
+ """
422
+ # If no URL is provided, show help
423
+ if url is None:
424
+ ctx = click.get_current_context()
425
+ click.echo(ctx.get_help())
426
+ ctx.exit(0)
427
+
428
+ try:
429
+ # Parse URL
430
+ base_url, virtual_path = parse_url(url)
431
+
432
+ # Parse data flags
433
+ data = parse_data_flags(data_flags) if data_flags else {}
434
+
435
+ # Map virtual path to method and params
436
+ method, params = map_virtual_path_to_method(virtual_path, data)
437
+
438
+ # Parse headers for authentication and custom HTTP headers
439
+ headers = parse_headers(header_flags) if header_flags else {}
440
+
441
+ # Make the MCP request using SDK (async)
442
+ result = asyncio.run(make_mcp_request(base_url, method, params, headers, verbose))
443
+
444
+ # Output the result
445
+ click.echo(json.dumps(result, indent=2))
446
+
447
+ except ValueError as e:
448
+ click.echo(f"Error: {e}", err=True)
449
+ sys.exit(1)
450
+ except ConnectionError as e:
451
+ click.echo(f"Error: Failed to connect to {url}: {e}", err=True)
452
+ sys.exit(1)
453
+ except TimeoutError as e:
454
+ click.echo(f"Error: Request timeout to {url}: {e}", err=True)
455
+ sys.exit(1)
456
+ except Exception as e:
457
+ # Handle MCP SDK exceptions and other errors
458
+ error_msg = str(e)
459
+ if "ConnectError" in error_msg or "Connection" in error_msg:
460
+ click.echo(f"Error: Failed to connect to {url}", err=True)
461
+ elif "Timeout" in error_msg:
462
+ click.echo(f"Error: Request timeout to {url}", err=True)
463
+ elif "ValidationError" in error_msg:
464
+ click.echo(f"Error: Invalid response from server: {e}", err=True)
465
+ else:
466
+ click.echo(f"Error: {e}", err=True)
467
+ sys.exit(1)
468
+
469
+
470
+ if __name__ == "__main__":
471
+ main()