mcp-cli-skill 0.7.0__tar.gz → 0.8.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_cli_skill-0.7.0 → mcp_cli_skill-0.8.0}/PKG-INFO +14 -1
- {mcp_cli_skill-0.7.0 → mcp_cli_skill-0.8.0}/README.md +13 -0
- {mcp_cli_skill-0.7.0 → mcp_cli_skill-0.8.0}/SKILL.md +12 -0
- {mcp_cli_skill-0.7.0 → mcp_cli_skill-0.8.0}/scripts/mcp_call.py +406 -14
- {mcp_cli_skill-0.7.0 → mcp_cli_skill-0.8.0}/src/mcp_cli_skill/__init__.py +1 -1
- {mcp_cli_skill-0.7.0 → mcp_cli_skill-0.8.0}/src/mcp_cli_skill/cli.py +406 -14
- mcp_cli_skill-0.8.0/tests/test_oauth.py +368 -0
- {mcp_cli_skill-0.7.0 → mcp_cli_skill-0.8.0}/.gitignore +0 -0
- {mcp_cli_skill-0.7.0 → mcp_cli_skill-0.8.0}/pyproject.toml +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: mcp-cli-skill
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: Call any MCP server tool from the command line with shell composition support
|
|
5
5
|
Project-URL: Homepage, https://github.com/wise-toddler/mcp-cli-skill
|
|
6
6
|
Project-URL: Repository, https://github.com/wise-toddler/mcp-cli-skill
|
|
@@ -54,6 +54,19 @@ mcp-call --remove myserver
|
|
|
54
54
|
mcp-call --sync # re-sync from Claude configs
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
+
## OAuth servers
|
|
58
|
+
|
|
59
|
+
HTTP servers that use MCP OAuth (dynamic client registration + PKCE) need a one-time browser login:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
mcp-call --add-http emergent https://mcp.emergent.sh/
|
|
63
|
+
mcp-call --login emergent # opens the browser; approve, then "Logged in to emergent."
|
|
64
|
+
mcp-call emergent --tools # bearer token is sent and refreshed automatically
|
|
65
|
+
mcp-call --logout emergent # forget the local tokens (no server-side revocation)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Tokens live in `~/.mcp-cli/tokens.json` (mode 0600), bound to the server's URL. A call to a server that needs login exits with code 4 and says which `--login` to run. `--login` waits up to `MCP_CALL_LOGIN_TIMEOUT` seconds (default 300) for the browser; over SSH it prints the URL instead of opening a browser. A static `Authorization` header (`--header`) always wins over OAuth.
|
|
69
|
+
|
|
57
70
|
## Shell completion
|
|
58
71
|
|
|
59
72
|
Tab completion suggests server names, tool names, and flag names.
|
|
@@ -38,6 +38,19 @@ mcp-call --remove myserver
|
|
|
38
38
|
mcp-call --sync # re-sync from Claude configs
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
+
## OAuth servers
|
|
42
|
+
|
|
43
|
+
HTTP servers that use MCP OAuth (dynamic client registration + PKCE) need a one-time browser login:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
mcp-call --add-http emergent https://mcp.emergent.sh/
|
|
47
|
+
mcp-call --login emergent # opens the browser; approve, then "Logged in to emergent."
|
|
48
|
+
mcp-call emergent --tools # bearer token is sent and refreshed automatically
|
|
49
|
+
mcp-call --logout emergent # forget the local tokens (no server-side revocation)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Tokens live in `~/.mcp-cli/tokens.json` (mode 0600), bound to the server's URL. A call to a server that needs login exits with code 4 and says which `--login` to run. `--login` waits up to `MCP_CALL_LOGIN_TIMEOUT` seconds (default 300) for the browser; over SSH it prints the URL instead of opening a browser. A static `Authorization` header (`--header`) always wins over OAuth.
|
|
53
|
+
|
|
41
54
|
## Shell completion
|
|
42
55
|
|
|
43
56
|
Tab completion suggests server names, tool names, and flag names.
|
|
@@ -23,6 +23,8 @@ echo '{}' | mcp-call <server> <tool> # call with stdin JSON
|
|
|
23
23
|
mcp-call --add <name> <cmd> [args] [--env K=V ...] # add stdio server
|
|
24
24
|
mcp-call --add-http <name> <url> [--header 'K: V' ...] # add HTTP server (auth via --header/-H)
|
|
25
25
|
mcp-call --remove <name> # remove server
|
|
26
|
+
mcp-call --login <name> # OAuth browser login (the user runs this, not you)
|
|
27
|
+
mcp-call --logout <name> # forget stored OAuth tokens
|
|
26
28
|
mcp-call --sync # re-sync from Claude configs
|
|
27
29
|
mcp-call --completion <bash|zsh|fish> # print shell completion hook
|
|
28
30
|
mcp-call --refresh-completions # cache tool lists for tab completion
|
|
@@ -40,6 +42,8 @@ mcp-call --remove myredash
|
|
|
40
42
|
mcp-call --sync
|
|
41
43
|
```
|
|
42
44
|
|
|
45
|
+
If a call exits 4 with "needs interactive browser login", ask the user to run the `mcp-call --login <name>` command it prints; don't run it yourself.
|
|
46
|
+
|
|
43
47
|
## Examples
|
|
44
48
|
|
|
45
49
|
```bash
|
|
@@ -86,3 +90,11 @@ Prefer this over MCP tool calls when:
|
|
|
86
90
|
## Arg Types
|
|
87
91
|
|
|
88
92
|
Values auto-parse: `--id=42` → int, `--flag=true` → bool, `--name=hello` → string.
|
|
93
|
+
|
|
94
|
+
## Colliding param names
|
|
95
|
+
|
|
96
|
+
If a tool has a param named `help`, `schema`, or `input-json` (which clash with mcp-call's own flags), pass it after `--`:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
mcp-call srv tool --id=5 -- --help=true --schema=foo # everything after -- goes to the tool verbatim
|
|
100
|
+
```
|
|
@@ -1,17 +1,27 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""Call any MCP server tool from CLI with --flag=value args."""
|
|
3
3
|
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import http.server
|
|
4
6
|
import json
|
|
5
7
|
import os
|
|
8
|
+
import secrets
|
|
6
9
|
import subprocess
|
|
7
10
|
import sys
|
|
8
11
|
import tempfile
|
|
12
|
+
import threading
|
|
9
13
|
import re
|
|
10
14
|
import shutil
|
|
11
15
|
import time
|
|
12
16
|
import urllib.parse
|
|
13
17
|
import urllib.request
|
|
14
18
|
import urllib.error
|
|
19
|
+
import webbrowser
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
import fcntl
|
|
23
|
+
except ImportError: # Windows: the token store runs without a lock
|
|
24
|
+
fcntl = None
|
|
15
25
|
|
|
16
26
|
MCP_CALL_TMPDIR = os.path.join(tempfile.gettempdir(), "mcp-call")
|
|
17
27
|
CONFIG_DIR = os.path.expanduser("~/.mcp-cli")
|
|
@@ -19,10 +29,15 @@ CONFIG_PATH = os.path.join(CONFIG_DIR, "servers.json")
|
|
|
19
29
|
CACHE_DIR = os.path.join(CONFIG_DIR, "cache")
|
|
20
30
|
CLAUDE_SETTINGS = os.path.expanduser("~/.claude/settings.json")
|
|
21
31
|
CLAUDE_JSON = os.path.expanduser("~/.claude.json")
|
|
32
|
+
TOKENS_PATH = os.path.join(CONFIG_DIR, "tokens.json") # OAuth tokens, mode 0600
|
|
33
|
+
LOGIN_TIMEOUT = int(os.environ.get("MCP_CALL_LOGIN_TIMEOUT") or 300) # seconds --login waits for the browser
|
|
34
|
+
OAUTH_HTTP_TIMEOUT = 30 # every OAuth request gets an explicit timeout
|
|
35
|
+
EXIT_NEEDS_LOGIN = 4 # exit code when the user must run --login
|
|
22
36
|
|
|
23
37
|
# CLI flags that don't take a positional server/tool — used for completion.
|
|
24
38
|
META_FLAGS = (
|
|
25
39
|
"--servers", "--sync", "--add", "--add-http", "--remove",
|
|
40
|
+
"--login", "--logout",
|
|
26
41
|
"--completion", "--refresh-completions", "--clear-cache",
|
|
27
42
|
"--version", "--help",
|
|
28
43
|
)
|
|
@@ -165,7 +180,7 @@ def parse_args():
|
|
|
165
180
|
break
|
|
166
181
|
sys.exit(0)
|
|
167
182
|
if not args or args[0] in ("-h", "--help"):
|
|
168
|
-
print("Usage: mcp-call <server> <tool> [--key=value ...] [--input-json '{...}']", file=sys.stderr)
|
|
183
|
+
print("Usage: mcp-call <server> <tool> [--key=value ...] [--input-json '{...}'] [-- <tool params verbatim>]", file=sys.stderr)
|
|
169
184
|
print(" mcp-call --servers", file=sys.stderr)
|
|
170
185
|
print(" mcp-call <server> --tools", file=sys.stderr)
|
|
171
186
|
print(" mcp-call <server> --discover", file=sys.stderr)
|
|
@@ -174,6 +189,8 @@ def parse_args():
|
|
|
174
189
|
print(" mcp-call --add <name> <command> [args...] [--env KEY=VAL ...]", file=sys.stderr)
|
|
175
190
|
print(" mcp-call --add-http <name> <url>", file=sys.stderr)
|
|
176
191
|
print(" mcp-call --remove <name>", file=sys.stderr)
|
|
192
|
+
print(" mcp-call --login <name> (OAuth browser login for an HTTP server)", file=sys.stderr)
|
|
193
|
+
print(" mcp-call --logout <name> (forget stored OAuth tokens)", file=sys.stderr)
|
|
177
194
|
print(" mcp-call --sync", file=sys.stderr)
|
|
178
195
|
print(" mcp-call --completion <bash|zsh|fish>", file=sys.stderr)
|
|
179
196
|
print(" mcp-call --refresh-completions (cache tool lists for tab completion)", file=sys.stderr)
|
|
@@ -210,6 +227,11 @@ def parse_args():
|
|
|
210
227
|
print("Usage: mcp-call --remove <name>", file=sys.stderr)
|
|
211
228
|
sys.exit(1)
|
|
212
229
|
return "__remove__", args[1], {}
|
|
230
|
+
if args[0] in ("--login", "--logout"):
|
|
231
|
+
if len(args) < 2:
|
|
232
|
+
print(f"Usage: mcp-call {args[0]} <name>", file=sys.stderr)
|
|
233
|
+
sys.exit(1)
|
|
234
|
+
return f"__{args[0][2:]}__", args[1], {}
|
|
213
235
|
if args[0] == "--sync":
|
|
214
236
|
return "__sync__", None, {}
|
|
215
237
|
if args[0] == "--completion":
|
|
@@ -221,6 +243,9 @@ def parse_args():
|
|
|
221
243
|
return "__clear_cache__", args[1] if len(args) > 1 else None, {}
|
|
222
244
|
|
|
223
245
|
server = args[0]
|
|
246
|
+
# `mcp-call <server> --login|--logout` alias the top-level flags
|
|
247
|
+
if len(args) > 1 and args[1] in ("--login", "--logout"):
|
|
248
|
+
return f"__{args[1][2:]}__", server, {}
|
|
224
249
|
if len(args) < 2 or args[1] == "--tools":
|
|
225
250
|
return server, "__tools__", {}
|
|
226
251
|
if args[1] == "--discover":
|
|
@@ -229,17 +254,20 @@ def parse_args():
|
|
|
229
254
|
tool = args[1]
|
|
230
255
|
tool_args = {}
|
|
231
256
|
i = 2
|
|
257
|
+
verbatim = False # after a bare `--`, everything is tool params, never meta-flags
|
|
232
258
|
while i < len(args):
|
|
233
259
|
arg = args[i]
|
|
234
|
-
if arg == "--
|
|
260
|
+
if not verbatim and arg == "--":
|
|
261
|
+
verbatim = True
|
|
262
|
+
elif not verbatim and arg == "--schema":
|
|
235
263
|
return server, "__schema__", {"_tool": tool}
|
|
236
|
-
elif arg in ("--help", "-h"):
|
|
264
|
+
elif not verbatim and arg in ("--help", "-h"):
|
|
237
265
|
return server, "__help__", {"_tool": tool}
|
|
238
|
-
elif arg == "--input-json" and i + 1 < len(args):
|
|
266
|
+
elif not verbatim and arg == "--input-json" and i + 1 < len(args):
|
|
239
267
|
tool_args.update(json.loads(args[i + 1]))
|
|
240
268
|
i += 2
|
|
241
269
|
continue
|
|
242
|
-
elif arg.startswith("--input-json="):
|
|
270
|
+
elif not verbatim and arg.startswith("--input-json="):
|
|
243
271
|
tool_args.update(json.loads(arg[13:]))
|
|
244
272
|
elif arg.startswith("--") and "=" in arg:
|
|
245
273
|
key, val = arg[2:].split("=", 1)
|
|
@@ -303,28 +331,41 @@ def _expand_env(val):
|
|
|
303
331
|
class HttpSession:
|
|
304
332
|
"""Manages HTTP MCP session with session ID tracking."""
|
|
305
333
|
|
|
306
|
-
def __init__(self, url, extra_headers=None):
|
|
334
|
+
def __init__(self, url, extra_headers=None, server_name=None):
|
|
307
335
|
self.url = _expand_env(url)
|
|
336
|
+
self.config_url = self.url # pre-redirect URL; credentials stay on its origin
|
|
308
337
|
self.session_id = None
|
|
309
338
|
self.extra_headers = {k: _expand_env(v) for k, v in (extra_headers or {}).items()}
|
|
339
|
+
self.server_name = server_name
|
|
340
|
+
# a static Authorization header always wins over stored OAuth tokens
|
|
341
|
+
self.static_auth = any(k.lower() == "authorization" for k in self.extra_headers)
|
|
342
|
+
self._oauth_token = None # bearer we sent from the token store
|
|
343
|
+
if server_name and not self.static_auth:
|
|
344
|
+
self.extra_headers.update(oauth_header(server_name, self.url))
|
|
345
|
+
self._oauth_token = self.extra_headers.get("Authorization", "").removeprefix("Bearer ") or None
|
|
310
346
|
|
|
311
347
|
def _send(self, data, headers, timeout=None, max_redirects=3):
|
|
312
|
-
# Long-running tool calls (bash, LLM, etc) can exceed 30s; make it tunable.
|
|
313
|
-
if timeout is None:
|
|
314
|
-
timeout = int(os.environ.get("MCP_CALL_HTTP_TIMEOUT", "300"))
|
|
315
348
|
"""POST data and follow 307/308 redirects preserving method+body.
|
|
316
349
|
|
|
317
350
|
urllib's default HTTPRedirectHandler does NOT follow 307/308 on POST,
|
|
318
351
|
only on GET/HEAD. We handle them explicitly here.
|
|
319
352
|
"""
|
|
353
|
+
# Long-running tool calls (bash, LLM, etc) can exceed 30s; make it tunable.
|
|
354
|
+
if timeout is None:
|
|
355
|
+
timeout = int(os.environ.get("MCP_CALL_HTTP_TIMEOUT", "300"))
|
|
320
356
|
url = self.url
|
|
321
357
|
for _ in range(max_redirects + 1):
|
|
358
|
+
if _origin(url) != _origin(self.config_url):
|
|
359
|
+
# never forward credentials to another scheme/host/port
|
|
360
|
+
headers = {k: v for k, v in headers.items() if k.lower() != "authorization"}
|
|
322
361
|
req = urllib.request.Request(url, data=data, headers=headers)
|
|
323
362
|
try:
|
|
324
363
|
return urllib.request.urlopen(req, timeout=timeout)
|
|
325
364
|
except urllib.error.HTTPError as e:
|
|
326
365
|
if e.code in (307, 308) and e.headers.get("Location"):
|
|
327
366
|
new_url = urllib.parse.urljoin(url, e.headers["Location"])
|
|
367
|
+
if _origin(url)[0] == "https" and _origin(new_url)[0] != "https":
|
|
368
|
+
raise # refuse https -> http downgrade
|
|
328
369
|
try:
|
|
329
370
|
e.close()
|
|
330
371
|
except Exception:
|
|
@@ -335,7 +376,7 @@ class HttpSession:
|
|
|
335
376
|
raise
|
|
336
377
|
raise urllib.error.HTTPError(url, 308, "Too many redirects", None, None)
|
|
337
378
|
|
|
338
|
-
def rpc(self, method, params=None, msg_id=1):
|
|
379
|
+
def rpc(self, method, params=None, msg_id=1, _retried=False):
|
|
339
380
|
"""Send JSON-RPC over HTTP and return response."""
|
|
340
381
|
msg = {"jsonrpc": "2.0", "method": method, "id": msg_id}
|
|
341
382
|
if params:
|
|
@@ -362,6 +403,22 @@ class HttpSession:
|
|
|
362
403
|
return json.loads(body)
|
|
363
404
|
except urllib.error.HTTPError as e:
|
|
364
405
|
body = e.read().decode() if e.fp else ""
|
|
406
|
+
# OAuth 401: refresh + retry once; decide on "did we send a token", not the 401's error code
|
|
407
|
+
if e.code == 401 and self.server_name and not self.static_auth and _origin(self.url) == _origin(self.config_url):
|
|
408
|
+
name = self.server_name
|
|
409
|
+
if self._oauth_token and not _retried:
|
|
410
|
+
fresh = oauth_header(name, self.config_url, failed_token=self._oauth_token)
|
|
411
|
+
self.extra_headers.pop("Authorization", None)
|
|
412
|
+
self.extra_headers.update(fresh)
|
|
413
|
+
self._oauth_token = fresh.get("Authorization", "").removeprefix("Bearer ") or None
|
|
414
|
+
if self._oauth_token:
|
|
415
|
+
return self.rpc(method, params, msg_id, _retried=True)
|
|
416
|
+
if self._oauth_token:
|
|
417
|
+
print(f"Error: {name} rejected a freshly refreshed token (HTTP 401). Ask the user to run: mcp-call --logout {name} && mcp-call --login {name}", file=sys.stderr)
|
|
418
|
+
sys.exit(EXIT_NEEDS_LOGIN)
|
|
419
|
+
if "resource_metadata=" in e.headers.get("WWW-Authenticate", ""):
|
|
420
|
+
print(f"Error: {name} needs interactive browser login. Ask the user to run: mcp-call --login {name} (do not run it yourself; it opens a browser).", file=sys.stderr)
|
|
421
|
+
sys.exit(EXIT_NEEDS_LOGIN)
|
|
365
422
|
print(f"Error: HTTP {e.code} from {self.url}", file=sys.stderr)
|
|
366
423
|
if body.strip():
|
|
367
424
|
# strip HTML, show first 200 chars
|
|
@@ -418,9 +475,9 @@ def http_init(session):
|
|
|
418
475
|
|
|
419
476
|
|
|
420
477
|
|
|
421
|
-
def http_call_tool(url, tool_name, tool_args, extra_headers=None):
|
|
478
|
+
def http_call_tool(url, tool_name, tool_args, extra_headers=None, server_name=None):
|
|
422
479
|
"""Call a tool on HTTP MCP server."""
|
|
423
|
-
session = HttpSession(url, extra_headers)
|
|
480
|
+
session = HttpSession(url, extra_headers, server_name)
|
|
424
481
|
http_init(session)
|
|
425
482
|
resp = session.rpc("tools/call", {"name": tool_name, "arguments": tool_args}, msg_id=3)
|
|
426
483
|
if not resp:
|
|
@@ -432,6 +489,315 @@ def http_call_tool(url, tool_name, tool_args, extra_headers=None):
|
|
|
432
489
|
_print_result(resp.get("result", {}))
|
|
433
490
|
|
|
434
491
|
|
|
492
|
+
# --- OAuth ---
|
|
493
|
+
# Browser login via discovery + dynamic client registration + PKCE; tokens never printed.
|
|
494
|
+
|
|
495
|
+
_tokens_warned = False # warn about a corrupt tokens.json once per run
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _die(msg, code=1):
|
|
499
|
+
"""Print msg to stderr and exit."""
|
|
500
|
+
print(msg, file=sys.stderr)
|
|
501
|
+
sys.exit(code)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _clean(text):
|
|
505
|
+
"""Strip control chars from server-supplied text before printing it."""
|
|
506
|
+
return re.sub(r"[\x00-\x1f\x7f-\x9f]", "", str(text))[:300]
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _origin(url):
|
|
510
|
+
"""Return (scheme, host, port) of a URL for same-origin checks."""
|
|
511
|
+
p = urllib.parse.urlsplit(url)
|
|
512
|
+
return p.scheme, p.hostname, p.port or (443 if p.scheme == "https" else 80)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _norm_url(url):
|
|
516
|
+
"""Normalize an empty URL path to '/' so https://x and https://x/ compare equal."""
|
|
517
|
+
p = urllib.parse.urlsplit(url)
|
|
518
|
+
return p._replace(path=p.path or "/").geturl()
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _require_https(url):
|
|
522
|
+
"""Abort unless url is https (plain http only for loopback hosts, e.g. tests)."""
|
|
523
|
+
p = urllib.parse.urlsplit(url)
|
|
524
|
+
if p.scheme != "https" and not (p.scheme == "http" and p.hostname in ("127.0.0.1", "localhost", "::1")):
|
|
525
|
+
_die(f"Error: login failed: refusing non-HTTPS OAuth URL {_clean(url)!r}.")
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _flock(path, wait):
|
|
529
|
+
"""Open path and flock it exclusively within `wait` seconds; None on timeout."""
|
|
530
|
+
os.makedirs(CONFIG_DIR, mode=0o700, exist_ok=True)
|
|
531
|
+
f = open(path, "a")
|
|
532
|
+
deadline = time.time() + wait
|
|
533
|
+
while fcntl:
|
|
534
|
+
try:
|
|
535
|
+
fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
536
|
+
break
|
|
537
|
+
except OSError:
|
|
538
|
+
if time.time() >= deadline:
|
|
539
|
+
f.close()
|
|
540
|
+
return None
|
|
541
|
+
time.sleep(0.05)
|
|
542
|
+
return f
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _tokens_lock():
|
|
546
|
+
"""Lock the token store for a read-modify-write; use as `with _tokens_lock():`."""
|
|
547
|
+
f = _flock(TOKENS_PATH + ".lock", 10)
|
|
548
|
+
if not f:
|
|
549
|
+
_die(f"Error: timed out waiting for {TOKENS_PATH}.lock")
|
|
550
|
+
return f
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _tokens_read():
|
|
554
|
+
"""Load tokens.json; if unreadable/corrupt, warn once and act as logged out."""
|
|
555
|
+
global _tokens_warned
|
|
556
|
+
if not os.path.exists(TOKENS_PATH):
|
|
557
|
+
return {}
|
|
558
|
+
try:
|
|
559
|
+
with open(TOKENS_PATH) as f:
|
|
560
|
+
store = json.load(f)
|
|
561
|
+
if isinstance(store, dict) and all(isinstance(v, dict) for v in store.values()):
|
|
562
|
+
return store
|
|
563
|
+
except (OSError, ValueError):
|
|
564
|
+
pass
|
|
565
|
+
if not _tokens_warned:
|
|
566
|
+
_tokens_warned = True
|
|
567
|
+
print(f"Warning: ignoring unreadable {TOKENS_PATH}", file=sys.stderr)
|
|
568
|
+
return {}
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _tokens_write(store):
|
|
572
|
+
"""Atomically replace tokens.json (0600); call while holding _tokens_lock()."""
|
|
573
|
+
fd, tmp = tempfile.mkstemp(dir=CONFIG_DIR, prefix=".tokens-")
|
|
574
|
+
os.chmod(tmp, 0o600)
|
|
575
|
+
with os.fdopen(fd, "w") as f:
|
|
576
|
+
json.dump(store, f, indent=2)
|
|
577
|
+
f.flush()
|
|
578
|
+
os.fsync(f.fileno())
|
|
579
|
+
os.replace(tmp, TOKENS_PATH)
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _tokens_put(name, entry):
|
|
583
|
+
"""Set (or with entry=None delete) one server's entry under the lock; return the old one."""
|
|
584
|
+
with _tokens_lock():
|
|
585
|
+
store = _tokens_read()
|
|
586
|
+
old = store.pop(name, None)
|
|
587
|
+
if entry is not None:
|
|
588
|
+
store[name] = entry
|
|
589
|
+
if old is not None or entry is not None:
|
|
590
|
+
_tokens_write(store)
|
|
591
|
+
return old
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _oauth_http(url, data=None, content_type="application/x-www-form-urlencoded"):
|
|
595
|
+
"""GET (or POST data to) an OAuth endpoint; return (status, JSON object or {})."""
|
|
596
|
+
headers = {"User-Agent": "mcp-cli/1.0", "Accept": "application/json"}
|
|
597
|
+
if data is not None:
|
|
598
|
+
headers["Content-Type"] = content_type
|
|
599
|
+
try:
|
|
600
|
+
req = urllib.request.Request(url, data=data, headers=headers)
|
|
601
|
+
with urllib.request.urlopen(req, timeout=OAUTH_HTTP_TIMEOUT) as resp:
|
|
602
|
+
status, body = resp.status, resp.read()
|
|
603
|
+
except urllib.error.HTTPError as e:
|
|
604
|
+
status, body = e.code, e.read()
|
|
605
|
+
try:
|
|
606
|
+
doc = json.loads(body)
|
|
607
|
+
except ValueError:
|
|
608
|
+
doc = {}
|
|
609
|
+
return status, doc if isinstance(doc, dict) else {}
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _oauth_meta(urls):
|
|
613
|
+
"""Return the first metadata document that answers 200, else {}."""
|
|
614
|
+
for u in urls:
|
|
615
|
+
_require_https(u)
|
|
616
|
+
status, doc = _oauth_http(u)
|
|
617
|
+
if status == 200 and doc:
|
|
618
|
+
return doc
|
|
619
|
+
return {}
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def oauth_header(name, url, failed_token=None):
|
|
623
|
+
"""Return {"Authorization": "Bearer …"} for a logged-in server (refreshing if needed), else {}."""
|
|
624
|
+
def usable(e):
|
|
625
|
+
# tokens are bound to the URL they were issued for (a changed URL never gets them)
|
|
626
|
+
return bool(e and e.get("access_token")) and _norm_url(e.get("url", "")) == _norm_url(url)
|
|
627
|
+
|
|
628
|
+
def fresh(e):
|
|
629
|
+
# after a 401 any token other than the rejected one is new; otherwise trust expires_at
|
|
630
|
+
return e["access_token"] != failed_token if failed_token else time.time() < e.get("expires_at", 0) - 60
|
|
631
|
+
|
|
632
|
+
entry = _tokens_read().get(name)
|
|
633
|
+
if not usable(entry):
|
|
634
|
+
return {}
|
|
635
|
+
if fresh(entry):
|
|
636
|
+
return {"Authorization": f"Bearer {entry['access_token']}"}
|
|
637
|
+
with _tokens_lock():
|
|
638
|
+
store = _tokens_read() # re-read: another mcp-call may have refreshed while we waited
|
|
639
|
+
entry = store.get(name)
|
|
640
|
+
if not usable(entry):
|
|
641
|
+
return {}
|
|
642
|
+
if fresh(entry):
|
|
643
|
+
return {"Authorization": f"Bearer {entry['access_token']}"}
|
|
644
|
+
if not entry.get("refresh_token"):
|
|
645
|
+
return {}
|
|
646
|
+
form = {"grant_type": "refresh_token", "refresh_token": entry["refresh_token"],
|
|
647
|
+
"client_id": entry["client_id"], "resource": entry["resource"]}
|
|
648
|
+
try:
|
|
649
|
+
status, tok = _oauth_http(entry["token_endpoint"], urllib.parse.urlencode(form).encode())
|
|
650
|
+
except OSError as e: # URLError, TimeoutError, connection reset: keep tokens
|
|
651
|
+
_die(f"Error: token refresh for {name} failed ({_clean(getattr(e, 'reason', e))}).")
|
|
652
|
+
if tok.get("error") == "invalid_grant":
|
|
653
|
+
# refresh token expired/revoked: drop tokens, keep client_id for the next --login
|
|
654
|
+
entry.pop("access_token", None)
|
|
655
|
+
entry.pop("refresh_token", None)
|
|
656
|
+
_tokens_write(store)
|
|
657
|
+
return {}
|
|
658
|
+
if status != 200 or not tok.get("access_token"):
|
|
659
|
+
_die(f"Error: token refresh for {name} failed (HTTP {status} {_clean(tok.get('error', ''))}".rstrip() + ").")
|
|
660
|
+
entry["access_token"] = tok["access_token"]
|
|
661
|
+
entry["refresh_token"] = tok.get("refresh_token") or entry["refresh_token"] # RFC 6749 §6: may be omitted
|
|
662
|
+
entry["expires_at"] = int(time.time()) + int(tok.get("expires_in") or 3600)
|
|
663
|
+
_tokens_write(store) # persist at once: refresh tokens may be single-use
|
|
664
|
+
return {"Authorization": f"Bearer {entry['access_token']}"}
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def oauth_logout(name):
|
|
668
|
+
"""Forget a server's stored OAuth entry locally (no server revocation); True if one existed."""
|
|
669
|
+
return name in _tokens_read() and _tokens_put(name, None) is not None
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def oauth_login(name, url):
|
|
673
|
+
"""Interactive browser login: discover, register client (DCR), PKCE authorize, store tokens."""
|
|
674
|
+
# fail fast instead of racing a second login; held until this function returns
|
|
675
|
+
lock = _flock(os.path.join(CONFIG_DIR, "login-" + re.sub(r"[^A-Za-z0-9._-]", "_", name) + ".lock"), 0)
|
|
676
|
+
if not lock:
|
|
677
|
+
_die(f"Error: login already in progress for '{name}'.")
|
|
678
|
+
# 1. discover: an unauthenticated initialize's 401 points at the protected-resource metadata
|
|
679
|
+
www_auth = ""
|
|
680
|
+
init = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
|
|
681
|
+
"protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "mcp-cli", "version": "1.0"}}}).encode()
|
|
682
|
+
try:
|
|
683
|
+
HttpSession(url)._send(init, {"Content-Type": "application/json", "User-Agent": "mcp-cli/1.0",
|
|
684
|
+
"Accept": "application/json, text/event-stream"}, timeout=OAUTH_HTTP_TIMEOUT).close()
|
|
685
|
+
except urllib.error.HTTPError as e:
|
|
686
|
+
www_auth = e.headers.get("WWW-Authenticate", "") if e.headers else ""
|
|
687
|
+
m = re.search(r'resource_metadata="([^"]+)"', www_auth)
|
|
688
|
+
p = urllib.parse.urlsplit(url)
|
|
689
|
+
well_known = f"{p.scheme}://{p.netloc}/.well-known/oauth-protected-resource" # RFC 9728, no trailing slash
|
|
690
|
+
prm = _oauth_meta([m.group(1)] if m else list(dict.fromkeys([well_known + p.path.rstrip("/"), well_known])))
|
|
691
|
+
if not prm:
|
|
692
|
+
_die(f"Error: login failed: {name} doesn't advertise OAuth (no protected-resource metadata).")
|
|
693
|
+
resource = prm.get("resource", "")
|
|
694
|
+
if _norm_url(resource) != _norm_url(url): # RFC 9728 §3.3: metadata for another resource could phish tokens
|
|
695
|
+
_die(f"Error: login failed: metadata is for resource {_clean(resource)!r}, not {url}.")
|
|
696
|
+
issuer = (prm.get("authorization_servers") or [""])[0]
|
|
697
|
+
_require_https(issuer)
|
|
698
|
+
ip = urllib.parse.urlsplit(issuer)
|
|
699
|
+
meta = _oauth_meta([f"{ip.scheme}://{ip.netloc}/.well-known/{doc}{ip.path.rstrip('/')}"
|
|
700
|
+
for doc in ("oauth-authorization-server", "openid-configuration")])
|
|
701
|
+
if meta.get("issuer") != issuer: # RFC 8414 §3.3: exact match
|
|
702
|
+
_die(f"Error: login failed: authorization server metadata doesn't match issuer {_clean(issuer)!r}.")
|
|
703
|
+
if "S256" not in meta.get("code_challenge_methods_supported", []) or not meta.get("registration_endpoint"):
|
|
704
|
+
_die("Error: server doesn't support dynamic client registration; use --header with a static token.")
|
|
705
|
+
for key in ("authorization_endpoint", "token_endpoint", "registration_endpoint"):
|
|
706
|
+
_require_https(meta.get(key, ""))
|
|
707
|
+
m = re.search(r'(?:^|[\s,])scope="([^"]*)"', www_auth)
|
|
708
|
+
scope = m.group(1) if m else " ".join(prm.get("scopes_supported", []))
|
|
709
|
+
|
|
710
|
+
# 2. loopback callback server; the first valid callback wins
|
|
711
|
+
state, verifier = secrets.token_urlsafe(16), secrets.token_urlsafe(48)
|
|
712
|
+
outcome, done = [], threading.Event()
|
|
713
|
+
|
|
714
|
+
class Callback(http.server.BaseHTTPRequestHandler):
|
|
715
|
+
timeout = 10 # idle browser preconnects can't block the server
|
|
716
|
+
|
|
717
|
+
def log_message(self, *args):
|
|
718
|
+
pass # the request line carries the auth code
|
|
719
|
+
|
|
720
|
+
def do_GET(self):
|
|
721
|
+
u = urllib.parse.urlsplit(self.path)
|
|
722
|
+
q = dict(urllib.parse.parse_qsl(u.query))
|
|
723
|
+
if u.path != "/callback":
|
|
724
|
+
return self._reply(404, "Not found.")
|
|
725
|
+
# a stray local request with the wrong state must not cancel the login
|
|
726
|
+
if not secrets.compare_digest(q.get("state", "").encode(), state.encode()):
|
|
727
|
+
return self._reply(400, "Invalid state.")
|
|
728
|
+
if q.get("error"):
|
|
729
|
+
outcome.append(("error", _clean(f"{q['error']} {q.get('error_description', '')}").strip()))
|
|
730
|
+
elif q.get("iss", issuer) != issuer: # RFC 9207 mix-up defense
|
|
731
|
+
outcome.append(("error", "callback came from a different issuer"))
|
|
732
|
+
elif q.get("code"):
|
|
733
|
+
outcome.append(("code", q["code"]))
|
|
734
|
+
else:
|
|
735
|
+
return self._reply(400, "Missing code.")
|
|
736
|
+
done.set()
|
|
737
|
+
self._reply(200, "mcp-call login finished. You can close this tab and return to the terminal.")
|
|
738
|
+
|
|
739
|
+
def _reply(self, status, text):
|
|
740
|
+
body = f"<!doctype html><meta charset=utf-8><title>mcp-call</title><p>{text}</p>".encode()
|
|
741
|
+
self.send_response(status)
|
|
742
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
743
|
+
self.send_header("Content-Length", str(len(body)))
|
|
744
|
+
self.send_header("Referrer-Policy", "no-referrer")
|
|
745
|
+
self.end_headers()
|
|
746
|
+
self.wfile.write(body)
|
|
747
|
+
|
|
748
|
+
httpd = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Callback)
|
|
749
|
+
redirect_uri = f"http://127.0.0.1:{httpd.server_address[1]}/callback" # host fixed, port may vary
|
|
750
|
+
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
|
751
|
+
try:
|
|
752
|
+
# 3. client_id: reuse ours for this issuer, else register one (DCR)
|
|
753
|
+
entry = {"url": url, "resource": resource, "issuer": issuer, "scope": scope,
|
|
754
|
+
"authorization_endpoint": meta["authorization_endpoint"], "token_endpoint": meta["token_endpoint"]}
|
|
755
|
+
old = _tokens_read().get(name) or {}
|
|
756
|
+
entry["client_id"] = old.get("client_id") if old.get("issuer") == issuer else None
|
|
757
|
+
if not entry["client_id"]:
|
|
758
|
+
reg = {"client_name": "mcp-call", "redirect_uris": [redirect_uri],
|
|
759
|
+
"grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"],
|
|
760
|
+
"token_endpoint_auth_method": "none"}
|
|
761
|
+
if scope:
|
|
762
|
+
reg["scope"] = scope
|
|
763
|
+
status, doc = _oauth_http(meta["registration_endpoint"], json.dumps(reg).encode(), "application/json")
|
|
764
|
+
entry["client_id"] = doc.get("client_id") if 200 <= status < 300 else None
|
|
765
|
+
if not entry["client_id"]:
|
|
766
|
+
_die(f"Error: login failed: client registration returned HTTP {status}.")
|
|
767
|
+
_tokens_put(name, entry) # persist now so a killed login leaves no orphan registration
|
|
768
|
+
# 4. PKCE authorize; the browser must open the URL itself (server sets a device cookie)
|
|
769
|
+
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
|
|
770
|
+
params = {"response_type": "code", "client_id": entry["client_id"], "redirect_uri": redirect_uri,
|
|
771
|
+
"code_challenge": challenge, "code_challenge_method": "S256", "state": state, "resource": resource}
|
|
772
|
+
if scope:
|
|
773
|
+
params["scope"] = scope
|
|
774
|
+
auth = meta["authorization_endpoint"]
|
|
775
|
+
auth_url = auth + ("&" if urllib.parse.urlsplit(auth).query else "?") + urllib.parse.urlencode(params)
|
|
776
|
+
print(f"Opening the browser to log in to {name}. If it doesn't open, visit:\n{auth_url}", file=sys.stderr)
|
|
777
|
+
# over SSH / headless Linux, webbrowser falls back to a blocking text browser
|
|
778
|
+
headless = sys.platform.startswith("linux") and not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
|
|
779
|
+
if not os.environ.get("SSH_CONNECTION") and not headless:
|
|
780
|
+
webbrowser.open(auth_url)
|
|
781
|
+
if not done.wait(LOGIN_TIMEOUT):
|
|
782
|
+
_die(f"Error: no callback received in {LOGIN_TIMEOUT}s. If the browser showed an error, run `mcp-call --logout {name}` and retry.")
|
|
783
|
+
finally:
|
|
784
|
+
httpd.shutdown()
|
|
785
|
+
httpd.server_close()
|
|
786
|
+
kind, value = outcome[0]
|
|
787
|
+
if kind == "error":
|
|
788
|
+
_die(f"Error: login failed: {value}")
|
|
789
|
+
# 5. exchange the code for tokens
|
|
790
|
+
form = {"grant_type": "authorization_code", "code": value, "redirect_uri": redirect_uri,
|
|
791
|
+
"client_id": entry["client_id"], "code_verifier": verifier, "resource": resource}
|
|
792
|
+
status, tok = _oauth_http(entry["token_endpoint"], urllib.parse.urlencode(form).encode())
|
|
793
|
+
if status != 200 or not tok.get("access_token") or str(tok.get("token_type", "")).lower() != "bearer":
|
|
794
|
+
_die(f"Error: login failed: token exchange returned HTTP {status} {_clean(tok.get('error', ''))}".rstrip() + ".")
|
|
795
|
+
entry.update(access_token=tok["access_token"], refresh_token=tok.get("refresh_token"),
|
|
796
|
+
expires_at=int(time.time()) + int(tok.get("expires_in") or 3600))
|
|
797
|
+
_tokens_put(name, entry)
|
|
798
|
+
print(f"Logged in to {name}.")
|
|
799
|
+
|
|
800
|
+
|
|
435
801
|
# --- Stdio transport ---
|
|
436
802
|
|
|
437
803
|
def send(proc, method, params=None, msg_id=None):
|
|
@@ -526,7 +892,7 @@ def fetch_tools(config, server_name=""):
|
|
|
526
892
|
"""Fetch tools list from server (HTTP or stdio), caching for completion."""
|
|
527
893
|
tools = []
|
|
528
894
|
if is_http(config):
|
|
529
|
-
session = HttpSession(config["url"], config.get("headers"))
|
|
895
|
+
session = HttpSession(config["url"], config.get("headers"), server_name)
|
|
530
896
|
http_init(session)
|
|
531
897
|
cursor, msg_id = None, 2
|
|
532
898
|
while True:
|
|
@@ -746,6 +1112,8 @@ def add_server(raw_args):
|
|
|
746
1112
|
def add_http_server(name, url, headers=None):
|
|
747
1113
|
"""Add a new HTTP MCP server."""
|
|
748
1114
|
servers = read_config()
|
|
1115
|
+
if name in servers:
|
|
1116
|
+
oauth_logout(name) # a re-added server must not inherit old tokens
|
|
749
1117
|
entry = {"type": "http", "url": url}
|
|
750
1118
|
if headers:
|
|
751
1119
|
entry["headers"] = headers
|
|
@@ -762,6 +1130,7 @@ def remove_server(name):
|
|
|
762
1130
|
sys.exit(1)
|
|
763
1131
|
del servers[name]
|
|
764
1132
|
_save_config(servers)
|
|
1133
|
+
oauth_logout(name)
|
|
765
1134
|
print(f"Removed server '{name}'")
|
|
766
1135
|
|
|
767
1136
|
|
|
@@ -864,6 +1233,8 @@ def _completion_candidates(prior, partial):
|
|
|
864
1233
|
# only those, not random server/tool names.
|
|
865
1234
|
if prior == ["--remove"] or prior == ["--clear-cache"]:
|
|
866
1235
|
return list(_load_json(CONFIG_PATH).keys())
|
|
1236
|
+
if prior in (["--login"], ["--logout"]):
|
|
1237
|
+
return [n for n, cfg in _load_json(CONFIG_PATH).items() if is_http(cfg)]
|
|
867
1238
|
if prior == ["--completion"]:
|
|
868
1239
|
return ["bash", "zsh", "fish"]
|
|
869
1240
|
# --add / --add-http take free-form command/URL/headers — nothing useful to suggest.
|
|
@@ -939,7 +1310,7 @@ def run_server(config, tool_name, tool_args, server_name=""):
|
|
|
939
1310
|
return
|
|
940
1311
|
# tool calls
|
|
941
1312
|
if is_http(config):
|
|
942
|
-
http_call_tool(config["url"], tool_name, tool_args, config.get("headers"))
|
|
1313
|
+
http_call_tool(config["url"], tool_name, tool_args, config.get("headers"), server_name)
|
|
943
1314
|
else:
|
|
944
1315
|
proc = spawn_server(config)
|
|
945
1316
|
try:
|
|
@@ -975,6 +1346,27 @@ def main():
|
|
|
975
1346
|
if server_name == "__remove__":
|
|
976
1347
|
remove_server(tool_name)
|
|
977
1348
|
return
|
|
1349
|
+
if server_name == "__login__":
|
|
1350
|
+
cfg = servers.get(tool_name, {})
|
|
1351
|
+
if not is_http(cfg):
|
|
1352
|
+
print(f"Error: '{tool_name}' is not a configured HTTP server.", file=sys.stderr)
|
|
1353
|
+
sys.exit(1)
|
|
1354
|
+
if any(k.lower() == "authorization" for k in cfg.get("headers") or {}):
|
|
1355
|
+
# the static header would always win over OAuth tokens
|
|
1356
|
+
print(f"Error: '{tool_name}' has a static Authorization header; re-add it without one to use --login.", file=sys.stderr)
|
|
1357
|
+
sys.exit(1)
|
|
1358
|
+
try:
|
|
1359
|
+
oauth_login(tool_name, _expand_env(cfg["url"]))
|
|
1360
|
+
except KeyboardInterrupt:
|
|
1361
|
+
print("\nLogin cancelled.", file=sys.stderr)
|
|
1362
|
+
sys.exit(130)
|
|
1363
|
+
except OSError as e: # network errors during discovery/registration/exchange
|
|
1364
|
+
print(f"Error: login failed: {_clean(getattr(e, 'reason', e))}", file=sys.stderr)
|
|
1365
|
+
sys.exit(1)
|
|
1366
|
+
return
|
|
1367
|
+
if server_name == "__logout__":
|
|
1368
|
+
print(f"Logged out of {tool_name}." if oauth_logout(tool_name) else f"Not logged in to {tool_name}.")
|
|
1369
|
+
return
|
|
978
1370
|
if server_name == "__sync__":
|
|
979
1371
|
sync_from_claude()
|
|
980
1372
|
return
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""MCP CLI - Call any MCP server tool from the command line."""
|
|
2
|
-
__version__ = "0.
|
|
2
|
+
__version__ = "0.8.0"
|