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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: mcp-cli-skill
3
- Version: 0.7.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 == "--schema":
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.7.0"
2
+ __version__ = "0.8.0"