mcp-cli-skill 0.7.1__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.1 → mcp_cli_skill-0.8.0}/PKG-INFO +14 -1
- {mcp_cli_skill-0.7.1 → mcp_cli_skill-0.8.0}/README.md +13 -0
- {mcp_cli_skill-0.7.1 → mcp_cli_skill-0.8.0}/SKILL.md +4 -0
- {mcp_cli_skill-0.7.1 → mcp_cli_skill-0.8.0}/scripts/mcp_call.py +398 -9
- {mcp_cli_skill-0.7.1 → mcp_cli_skill-0.8.0}/src/mcp_cli_skill/__init__.py +1 -1
- {mcp_cli_skill-0.7.1 → mcp_cli_skill-0.8.0}/src/mcp_cli_skill/cli.py +398 -9
- mcp_cli_skill-0.8.0/tests/test_oauth.py +368 -0
- {mcp_cli_skill-0.7.1 → mcp_cli_skill-0.8.0}/.gitignore +0 -0
- {mcp_cli_skill-0.7.1 → 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
|
|
@@ -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
|
)
|
|
@@ -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":
|
|
@@ -306,28 +331,41 @@ def _expand_env(val):
|
|
|
306
331
|
class HttpSession:
|
|
307
332
|
"""Manages HTTP MCP session with session ID tracking."""
|
|
308
333
|
|
|
309
|
-
def __init__(self, url, extra_headers=None):
|
|
334
|
+
def __init__(self, url, extra_headers=None, server_name=None):
|
|
310
335
|
self.url = _expand_env(url)
|
|
336
|
+
self.config_url = self.url # pre-redirect URL; credentials stay on its origin
|
|
311
337
|
self.session_id = None
|
|
312
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
|
|
313
346
|
|
|
314
347
|
def _send(self, data, headers, timeout=None, max_redirects=3):
|
|
315
|
-
# Long-running tool calls (bash, LLM, etc) can exceed 30s; make it tunable.
|
|
316
|
-
if timeout is None:
|
|
317
|
-
timeout = int(os.environ.get("MCP_CALL_HTTP_TIMEOUT", "300"))
|
|
318
348
|
"""POST data and follow 307/308 redirects preserving method+body.
|
|
319
349
|
|
|
320
350
|
urllib's default HTTPRedirectHandler does NOT follow 307/308 on POST,
|
|
321
351
|
only on GET/HEAD. We handle them explicitly here.
|
|
322
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"))
|
|
323
356
|
url = self.url
|
|
324
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"}
|
|
325
361
|
req = urllib.request.Request(url, data=data, headers=headers)
|
|
326
362
|
try:
|
|
327
363
|
return urllib.request.urlopen(req, timeout=timeout)
|
|
328
364
|
except urllib.error.HTTPError as e:
|
|
329
365
|
if e.code in (307, 308) and e.headers.get("Location"):
|
|
330
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
|
|
331
369
|
try:
|
|
332
370
|
e.close()
|
|
333
371
|
except Exception:
|
|
@@ -338,7 +376,7 @@ class HttpSession:
|
|
|
338
376
|
raise
|
|
339
377
|
raise urllib.error.HTTPError(url, 308, "Too many redirects", None, None)
|
|
340
378
|
|
|
341
|
-
def rpc(self, method, params=None, msg_id=1):
|
|
379
|
+
def rpc(self, method, params=None, msg_id=1, _retried=False):
|
|
342
380
|
"""Send JSON-RPC over HTTP and return response."""
|
|
343
381
|
msg = {"jsonrpc": "2.0", "method": method, "id": msg_id}
|
|
344
382
|
if params:
|
|
@@ -365,6 +403,22 @@ class HttpSession:
|
|
|
365
403
|
return json.loads(body)
|
|
366
404
|
except urllib.error.HTTPError as e:
|
|
367
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)
|
|
368
422
|
print(f"Error: HTTP {e.code} from {self.url}", file=sys.stderr)
|
|
369
423
|
if body.strip():
|
|
370
424
|
# strip HTML, show first 200 chars
|
|
@@ -421,9 +475,9 @@ def http_init(session):
|
|
|
421
475
|
|
|
422
476
|
|
|
423
477
|
|
|
424
|
-
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):
|
|
425
479
|
"""Call a tool on HTTP MCP server."""
|
|
426
|
-
session = HttpSession(url, extra_headers)
|
|
480
|
+
session = HttpSession(url, extra_headers, server_name)
|
|
427
481
|
http_init(session)
|
|
428
482
|
resp = session.rpc("tools/call", {"name": tool_name, "arguments": tool_args}, msg_id=3)
|
|
429
483
|
if not resp:
|
|
@@ -435,6 +489,315 @@ def http_call_tool(url, tool_name, tool_args, extra_headers=None):
|
|
|
435
489
|
_print_result(resp.get("result", {}))
|
|
436
490
|
|
|
437
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
|
+
|
|
438
801
|
# --- Stdio transport ---
|
|
439
802
|
|
|
440
803
|
def send(proc, method, params=None, msg_id=None):
|
|
@@ -529,7 +892,7 @@ def fetch_tools(config, server_name=""):
|
|
|
529
892
|
"""Fetch tools list from server (HTTP or stdio), caching for completion."""
|
|
530
893
|
tools = []
|
|
531
894
|
if is_http(config):
|
|
532
|
-
session = HttpSession(config["url"], config.get("headers"))
|
|
895
|
+
session = HttpSession(config["url"], config.get("headers"), server_name)
|
|
533
896
|
http_init(session)
|
|
534
897
|
cursor, msg_id = None, 2
|
|
535
898
|
while True:
|
|
@@ -749,6 +1112,8 @@ def add_server(raw_args):
|
|
|
749
1112
|
def add_http_server(name, url, headers=None):
|
|
750
1113
|
"""Add a new HTTP MCP server."""
|
|
751
1114
|
servers = read_config()
|
|
1115
|
+
if name in servers:
|
|
1116
|
+
oauth_logout(name) # a re-added server must not inherit old tokens
|
|
752
1117
|
entry = {"type": "http", "url": url}
|
|
753
1118
|
if headers:
|
|
754
1119
|
entry["headers"] = headers
|
|
@@ -765,6 +1130,7 @@ def remove_server(name):
|
|
|
765
1130
|
sys.exit(1)
|
|
766
1131
|
del servers[name]
|
|
767
1132
|
_save_config(servers)
|
|
1133
|
+
oauth_logout(name)
|
|
768
1134
|
print(f"Removed server '{name}'")
|
|
769
1135
|
|
|
770
1136
|
|
|
@@ -867,6 +1233,8 @@ def _completion_candidates(prior, partial):
|
|
|
867
1233
|
# only those, not random server/tool names.
|
|
868
1234
|
if prior == ["--remove"] or prior == ["--clear-cache"]:
|
|
869
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)]
|
|
870
1238
|
if prior == ["--completion"]:
|
|
871
1239
|
return ["bash", "zsh", "fish"]
|
|
872
1240
|
# --add / --add-http take free-form command/URL/headers — nothing useful to suggest.
|
|
@@ -942,7 +1310,7 @@ def run_server(config, tool_name, tool_args, server_name=""):
|
|
|
942
1310
|
return
|
|
943
1311
|
# tool calls
|
|
944
1312
|
if is_http(config):
|
|
945
|
-
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)
|
|
946
1314
|
else:
|
|
947
1315
|
proc = spawn_server(config)
|
|
948
1316
|
try:
|
|
@@ -978,6 +1346,27 @@ def main():
|
|
|
978
1346
|
if server_name == "__remove__":
|
|
979
1347
|
remove_server(tool_name)
|
|
980
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
|
|
981
1370
|
if server_name == "__sync__":
|
|
982
1371
|
sync_from_claude()
|
|
983
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"
|