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.
- mcp_curl-0.2.1.dist-info/LICENSE +21 -0
- mcp_curl-0.2.1.dist-info/METADATA +383 -0
- mcp_curl-0.2.1.dist-info/RECORD +8 -0
- mcp_curl-0.2.1.dist-info/WHEEL +5 -0
- mcp_curl-0.2.1.dist-info/entry_points.txt +2 -0
- mcp_curl-0.2.1.dist-info/top_level.txt +1 -0
- murl/__init__.py +3 -0
- murl/cli.py +471 -0
|
@@ -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
|
+
[](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 @@
|
|
|
1
|
+
murl
|
murl/__init__.py
ADDED
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()
|