mcp2cli 2.6.1__tar.gz → 2.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.4
2
2
  Name: mcp2cli
3
- Version: 2.6.1
3
+ Version: 2.8.0
4
4
  Summary: Turn any MCP server or OpenAPI spec into a CLI
5
5
  Author: Stephan Fitzpatrick
6
6
  Author-email: Stephan Fitzpatrick <stephan@knowsuchagency.com>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mcp2cli"
3
- version = "2.6.1"
3
+ version = "2.8.0"
4
4
  description = "Turn any MCP server or OpenAPI spec into a CLI"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "2.6.0"
5
+ __version__ = "2.7.0"
6
6
 
7
7
  import argparse
8
8
  import copy
@@ -26,6 +26,8 @@ from http.server import HTTPServer, BaseHTTPRequestHandler
26
26
  from pathlib import Path
27
27
  from urllib.parse import parse_qs, urlparse
28
28
 
29
+ from datetime import datetime, timezone
30
+
29
31
  import anyio
30
32
  import httpx
31
33
 
@@ -33,6 +35,7 @@ CACHE_DIR = Path(
33
35
  os.environ.get("MCP2CLI_CACHE_DIR", Path.home() / ".cache" / "mcp2cli")
34
36
  )
35
37
  DEFAULT_CACHE_TTL = 3600
38
+ USAGE_FILE = CACHE_DIR / "usage.json"
36
39
  CONFIG_DIR = Path(
37
40
  os.environ.get("MCP2CLI_CONFIG_DIR", Path.home() / ".config" / "mcp2cli")
38
41
  )
@@ -387,6 +390,94 @@ def save_cache(key: str, data: dict):
387
390
  (CACHE_DIR / f"{key}.json").write_text(json.dumps(data))
388
391
 
389
392
 
393
+ # ---------------------------------------------------------------------------
394
+ # Usage tracking
395
+ # ---------------------------------------------------------------------------
396
+
397
+
398
+ def _load_usage() -> dict:
399
+ """Load the usage tracking file. Returns empty dict on any failure."""
400
+ if not USAGE_FILE.exists():
401
+ return {}
402
+ try:
403
+ return json.loads(USAGE_FILE.read_text())
404
+ except (json.JSONDecodeError, OSError):
405
+ return {}
406
+
407
+
408
+ def _save_usage(data: dict) -> None:
409
+ """Write usage data. Last-write-wins -- no file locking."""
410
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
411
+ USAGE_FILE.write_text(json.dumps(data, indent=2))
412
+
413
+
414
+ def record_usage(source_hash: str, tool_name: str) -> None:
415
+ """Increment the call count and update last_used for a tool."""
416
+ usage = _load_usage()
417
+ bucket = usage.setdefault(source_hash, {})
418
+ entry = bucket.setdefault(tool_name, {"count": 0, "last_used": ""})
419
+ entry["count"] += 1
420
+ entry["last_used"] = datetime.now(timezone.utc).isoformat()
421
+ _save_usage(usage)
422
+
423
+
424
+ def _source_hash_for(source: str) -> str:
425
+ """Derive a stable hash key from a source URL/command string."""
426
+ return hashlib.sha256(source.encode()).hexdigest()[:16]
427
+
428
+
429
+ def sort_commands(
430
+ commands: list["CommandDef"],
431
+ sort_mode: str,
432
+ source_hash: str,
433
+ ) -> list["CommandDef"]:
434
+ """Sort commands by the given mode using usage data.
435
+
436
+ Modes:
437
+ usage -- most-called first (default when usage data exists)
438
+ recent -- most-recently-used first
439
+ alpha -- alphabetical by name
440
+ default -- original insertion order
441
+ """
442
+ if sort_mode == "default":
443
+ return commands
444
+ if sort_mode == "alpha":
445
+ return sorted(commands, key=lambda c: c.name)
446
+
447
+ usage = _load_usage().get(source_hash, {})
448
+ if not usage:
449
+ return commands # no data, keep insertion order
450
+
451
+ def _usage_key(c: "CommandDef") -> str:
452
+ return c.tool_name or c.graphql_field_name or c.name
453
+
454
+ if sort_mode == "usage":
455
+ return sorted(
456
+ commands,
457
+ key=lambda c: usage.get(_usage_key(c), {}).get("count", 0),
458
+ reverse=True,
459
+ )
460
+ if sort_mode == "recent":
461
+ return sorted(
462
+ commands,
463
+ key=lambda c: usage.get(_usage_key(c), {}).get("last_used", ""),
464
+ reverse=True,
465
+ )
466
+ return commands
467
+
468
+
469
+ def _resolve_sort_mode(explicit_sort: str | None, source_hash: str) -> str:
470
+ """Determine the effective sort mode.
471
+
472
+ If the user passed --sort explicitly, use that. Otherwise, default to
473
+ 'usage' when usage data exists for this source, else 'default'.
474
+ """
475
+ if explicit_sort is not None:
476
+ return explicit_sort
477
+ usage = _load_usage().get(source_hash, {})
478
+ return "usage" if usage else "default"
479
+
480
+
390
481
  # ---------------------------------------------------------------------------
391
482
  # OAuth support
392
483
  # ---------------------------------------------------------------------------
@@ -1225,8 +1316,20 @@ def _wrap_description(description: str, indent: int, total_width: int = 110) ->
1225
1316
  )
1226
1317
 
1227
1318
 
1228
- def list_graphql_commands(commands: list[CommandDef], verbose: bool = False):
1319
+ def list_graphql_commands(
1320
+ commands: list[CommandDef],
1321
+ verbose: bool = False,
1322
+ compact: bool = False,
1323
+ source_hash: str = "",
1324
+ sort_mode: str | None = None,
1325
+ top: int | None = None,
1326
+ ):
1229
1327
  """Group commands by operation type and print."""
1328
+ commands = _apply_list_options(commands, source_hash, sort_mode, top)
1329
+
1330
+ if compact:
1331
+ print(" ".join(cmd.name for cmd in commands))
1332
+ return
1230
1333
 
1231
1334
  groups: dict[str, list[CommandDef]] = {}
1232
1335
  for cmd in commands:
@@ -1364,19 +1467,30 @@ def handle_graphql(
1364
1467
  jq_expr: str | None = None,
1365
1468
  head: int | None = None,
1366
1469
  verbose: bool = False,
1470
+ sort_mode: str | None = None,
1471
+ top: int | None = None,
1472
+ compact: bool = False,
1367
1473
  ):
1368
1474
  """Top-level handler for --graphql mode."""
1475
+ src_hash = _source_hash_for(url)
1369
1476
  schema = load_graphql_schema(url, auth_headers, cache_key, ttl, refresh, oauth_provider=oauth_provider)
1370
1477
  commands = extract_graphql_commands(schema)
1371
1478
 
1479
+ list_kwargs = dict(
1480
+ verbose=verbose, compact=compact,
1481
+ source_hash=src_hash, sort_mode=sort_mode, top=top,
1482
+ )
1483
+
1372
1484
  if list_mode:
1373
- list_graphql_commands(commands, verbose=verbose)
1485
+ list_graphql_commands(commands, **list_kwargs)
1374
1486
  return
1375
1487
 
1376
1488
  if not remaining:
1377
- print("Available operations:")
1378
- list_graphql_commands(commands, verbose=verbose)
1379
- print("\nUse --list for the same output, or provide a subcommand.")
1489
+ if not compact:
1490
+ print("Available operations:")
1491
+ list_graphql_commands(commands, **list_kwargs)
1492
+ if not compact:
1493
+ print("\nUse --list for the same output, or provide a subcommand.")
1380
1494
  return
1381
1495
 
1382
1496
  pre_for_gql = argparse.ArgumentParser(add_help=False)
@@ -1394,6 +1508,9 @@ def handle_graphql(
1394
1508
  jq_expr=jq_expr, head=head,
1395
1509
  )
1396
1510
 
1511
+ # Record usage after successful execution
1512
+ record_usage(src_hash, cmd.graphql_field_name or cmd.name)
1513
+
1397
1514
 
1398
1515
  # ---------------------------------------------------------------------------
1399
1516
  # Command filtering (bake mode)
@@ -1503,9 +1620,17 @@ _BAKE_NAME_RE = re.compile(r"^[a-z][a-z0-9-]*$")
1503
1620
 
1504
1621
  def _handle_bake(argv: list[str]) -> None:
1505
1622
  """Dispatch bake subcommands."""
1506
- if not argv:
1507
- print("Usage: mcp2cli bake <create|list|show|remove|update|install> ...")
1508
- sys.exit(1)
1623
+ if not argv or argv[0] in ("-h", "--help"):
1624
+ print("Usage: mcp2cli bake <command> [options]\n")
1625
+ print("Commands:")
1626
+ print(" create Save connection settings as a named baked tool")
1627
+ print(" list List all baked tools")
1628
+ print(" show Show config for a baked tool (secrets masked)")
1629
+ print(" remove Delete a baked tool")
1630
+ print(" update Update settings on an existing baked tool")
1631
+ print(" install Create a ~/.local/bin wrapper script")
1632
+ print("\nRun 'mcp2cli bake <command> --help' for command-specific help.")
1633
+ sys.exit(0 if argv else 1)
1509
1634
  sub = argv[0]
1510
1635
  rest = argv[1:]
1511
1636
  dispatch = {
@@ -1812,7 +1937,34 @@ def build_argparse(
1812
1937
  # ---------------------------------------------------------------------------
1813
1938
 
1814
1939
 
1815
- def list_openapi_commands(commands: list[CommandDef], verbose: bool = False):
1940
+ def _apply_list_options(
1941
+ commands: list[CommandDef],
1942
+ source_hash: str = "",
1943
+ sort_mode: str | None = None,
1944
+ top: int | None = None,
1945
+ ) -> list[CommandDef]:
1946
+ """Apply sort and top-N filtering to a command list."""
1947
+ effective_sort = _resolve_sort_mode(sort_mode, source_hash)
1948
+ commands = sort_commands(commands, effective_sort, source_hash)
1949
+ if top is not None:
1950
+ commands = commands[:top]
1951
+ return commands
1952
+
1953
+
1954
+ def list_openapi_commands(
1955
+ commands: list[CommandDef],
1956
+ verbose: bool = False,
1957
+ compact: bool = False,
1958
+ source_hash: str = "",
1959
+ sort_mode: str | None = None,
1960
+ top: int | None = None,
1961
+ ):
1962
+ commands = _apply_list_options(commands, source_hash, sort_mode, top)
1963
+
1964
+ if compact:
1965
+ print(" ".join(cmd.name for cmd in commands))
1966
+ return
1967
+
1816
1968
  groups: dict[str, list[CommandDef]] = {}
1817
1969
  for cmd in commands:
1818
1970
  prefix = cmd.name.split("-", 1)[0] if "-" in cmd.name else "other"
@@ -1832,7 +1984,20 @@ def list_openapi_commands(commands: list[CommandDef], verbose: bool = False):
1832
1984
  print(line)
1833
1985
 
1834
1986
 
1835
- def list_mcp_commands(commands: list[CommandDef], verbose: bool = False):
1987
+ def list_mcp_commands(
1988
+ commands: list[CommandDef],
1989
+ verbose: bool = False,
1990
+ compact: bool = False,
1991
+ source_hash: str = "",
1992
+ sort_mode: str | None = None,
1993
+ top: int | None = None,
1994
+ ):
1995
+ commands = _apply_list_options(commands, source_hash, sort_mode, top)
1996
+
1997
+ if compact:
1998
+ print(" ".join(cmd.name for cmd in commands))
1999
+ return
2000
+
1836
2001
  for cmd in commands:
1837
2002
  if cmd.description:
1838
2003
  if verbose:
@@ -2022,6 +2187,10 @@ def run_mcp_http(
2022
2187
  jq_expr: str | None = None,
2023
2188
  head: int | None = None,
2024
2189
  verbose: bool = False,
2190
+ sort_mode: str | None = None,
2191
+ top: int | None = None,
2192
+ compact: bool = False,
2193
+ source_hash: str = "",
2025
2194
  ):
2026
2195
  extra = dict(
2027
2196
  resource_action=resource_action,
@@ -2033,6 +2202,10 @@ def run_mcp_http(
2033
2202
  jq_expr=jq_expr,
2034
2203
  head=head,
2035
2204
  verbose=verbose,
2205
+ sort_mode=sort_mode,
2206
+ top=top,
2207
+ compact=compact,
2208
+ source_hash=source_hash,
2036
2209
  )
2037
2210
 
2038
2211
  async def _run():
@@ -2119,6 +2292,10 @@ def run_mcp_stdio(
2119
2292
  jq_expr: str | None = None,
2120
2293
  head: int | None = None,
2121
2294
  verbose: bool = False,
2295
+ sort_mode: str | None = None,
2296
+ top: int | None = None,
2297
+ compact: bool = False,
2298
+ source_hash: str = "",
2122
2299
  ):
2123
2300
  extra = dict(
2124
2301
  resource_action=resource_action,
@@ -2130,6 +2307,10 @@ def run_mcp_stdio(
2130
2307
  jq_expr=jq_expr,
2131
2308
  head=head,
2132
2309
  verbose=verbose,
2310
+ sort_mode=sort_mode,
2311
+ top=top,
2312
+ compact=compact,
2313
+ source_hash=source_hash,
2133
2314
  )
2134
2315
 
2135
2316
  import anyio
@@ -2182,6 +2363,10 @@ async def _mcp_session(
2182
2363
  jq_expr: str | None = None,
2183
2364
  head: int | None = None,
2184
2365
  verbose: bool = False,
2366
+ sort_mode: str | None = None,
2367
+ top: int | None = None,
2368
+ compact: bool = False,
2369
+ source_hash: str = "",
2185
2370
  ):
2186
2371
  # Handle resource operations
2187
2372
  if resource_action:
@@ -2199,6 +2384,11 @@ async def _mcp_session(
2199
2384
  )
2200
2385
  return
2201
2386
 
2387
+ list_kwargs = dict(
2388
+ verbose=verbose, compact=compact,
2389
+ source_hash=source_hash, sort_mode=sort_mode, top=top,
2390
+ )
2391
+
2202
2392
  if list_mode:
2203
2393
  result = await session.list_tools()
2204
2394
  tools = [
@@ -2215,10 +2405,12 @@ async def _mcp_session(
2215
2405
  if not commands:
2216
2406
  print(f"\nNo tools matching '{search_pattern}'.")
2217
2407
  return
2218
- print(f"\nTools matching '{search_pattern}':")
2408
+ if not compact:
2409
+ print(f"\nTools matching '{search_pattern}':")
2219
2410
  else:
2220
- print("\nAvailable tools:")
2221
- list_mcp_commands(commands, verbose=verbose)
2411
+ if not compact:
2412
+ print("\nAvailable tools:")
2413
+ list_mcp_commands(commands, **list_kwargs)
2222
2414
  return
2223
2415
 
2224
2416
  if tool_name is None:
@@ -2837,6 +3029,9 @@ def handle_mcp(
2837
3029
  jq_expr: str | None = None,
2838
3030
  head: int | None = None,
2839
3031
  verbose: bool = False,
3032
+ sort_mode: str | None = None,
3033
+ top: int | None = None,
3034
+ compact: bool = False,
2840
3035
  ):
2841
3036
  # Build a config dict for cache key generation (future-proof)
2842
3037
  config_for_cache = {
@@ -2846,8 +3041,9 @@ def handle_mcp(
2846
3041
  'env_vars': env_vars,
2847
3042
  'is_stdio': is_stdio,
2848
3043
  }
2849
-
3044
+
2850
3045
  key = cache_key_override or cache_key_for(config_for_cache)
3046
+ src_hash = _source_hash_for(source)
2851
3047
 
2852
3048
  # Resource/prompt operations skip the tool flow entirely
2853
3049
  if resource_action or prompt_action:
@@ -2868,9 +3064,14 @@ def handle_mcp(
2868
3064
  )
2869
3065
  return
2870
3066
 
3067
+ list_kwargs = dict(
3068
+ verbose=verbose, compact=compact,
3069
+ source_hash=src_hash, sort_mode=sort_mode, top=top,
3070
+ )
3071
+
2871
3072
  if list_mode:
2872
3073
  if bake_config and (bake_config.include or bake_config.exclude or bake_config.methods):
2873
- # Fetch tools, filter, then list don't delegate to unfiltered path
3074
+ # Fetch tools, filter, then list -- don't delegate to unfiltered path
2874
3075
  tools = _fetch_or_cache_mcp_tools(
2875
3076
  key, ttl, refresh, source, is_stdio, auth_headers, env_vars,
2876
3077
  transport=transport, oauth_provider=oauth_provider,
@@ -2879,8 +3080,9 @@ def handle_mcp(
2879
3080
  commands = filter_commands(
2880
3081
  commands, bake_config.include, bake_config.exclude, bake_config.methods,
2881
3082
  )
2882
- print("\nAvailable tools:")
2883
- list_mcp_commands(commands, verbose=verbose)
3083
+ if not compact:
3084
+ print("\nAvailable tools:")
3085
+ list_mcp_commands(commands, **list_kwargs)
2884
3086
  return
2885
3087
  _dispatch_mcp_call(
2886
3088
  source, is_stdio, auth_headers, env_vars,
@@ -2889,6 +3091,8 @@ def handle_mcp(
2889
3091
  search_pattern=search_pattern,
2890
3092
  jq_expr=jq_expr, head=head,
2891
3093
  verbose=verbose,
3094
+ sort_mode=sort_mode, top=top, compact=compact,
3095
+ source_hash=src_hash,
2892
3096
  )
2893
3097
  return
2894
3098
 
@@ -2905,9 +3109,11 @@ def handle_mcp(
2905
3109
  )
2906
3110
 
2907
3111
  if not remaining:
2908
- print("Available tools:")
2909
- list_mcp_commands(commands, verbose=verbose)
2910
- print("\nUse --list for the same output, or provide a subcommand.")
3112
+ if not compact:
3113
+ print("Available tools:")
3114
+ list_mcp_commands(commands, **list_kwargs)
3115
+ if not compact:
3116
+ print("\nUse --list for the same output, or provide a subcommand.")
2911
3117
  return
2912
3118
 
2913
3119
  pre = argparse.ArgumentParser(add_help=False)
@@ -2936,6 +3142,9 @@ def handle_mcp(
2936
3142
  jq_expr=jq_expr, head=head,
2937
3143
  )
2938
3144
 
3145
+ # Record usage after successful execution
3146
+ record_usage(src_hash, cmd.tool_name or cmd.name)
3147
+
2939
3148
 
2940
3149
  def _fetch_mcp_tools(
2941
3150
  source: str,
@@ -3074,7 +3283,19 @@ def main():
3074
3283
 
3075
3284
  def _build_main_parser() -> argparse.ArgumentParser:
3076
3285
  """Build the global ArgumentParser for _main_impl."""
3077
- pre = argparse.ArgumentParser(add_help=False, allow_abbrev=False)
3286
+ pre = argparse.ArgumentParser(
3287
+ add_help=False,
3288
+ allow_abbrev=False,
3289
+ epilog=(
3290
+ "subcommands:\n"
3291
+ " bake Manage baked tool configurations\n"
3292
+ " (create, list, show, remove, update, install)\n"
3293
+ " @<name> Run a previously baked tool\n"
3294
+ "\n"
3295
+ "Run 'mcp2cli bake --help' for bake subcommand details."
3296
+ ),
3297
+ formatter_class=argparse.RawDescriptionHelpFormatter,
3298
+ )
3078
3299
  pre.add_argument("--spec", default=None, help="OpenAPI spec URL or file path")
3079
3300
  pre.add_argument("--mcp", default=None, help="MCP server URL (HTTP/SSE)")
3080
3301
  pre.add_argument("--mcp-stdio", default=None, help="MCP server command (stdio)")
@@ -3110,6 +3331,30 @@ def _build_main_parser() -> argparse.ArgumentParser:
3110
3331
  dest="verbose",
3111
3332
  help="Show full tool descriptions in --list output, wrapped to terminal width (default: truncated with ...)",
3112
3333
  )
3334
+ pre.add_argument(
3335
+ "--sort",
3336
+ choices=["usage", "recent", "alpha", "default"],
3337
+ default=None,
3338
+ dest="sort_mode",
3339
+ help=(
3340
+ "Sort order for --list output. 'usage' sorts by call frequency, "
3341
+ "'recent' by last-used time, 'alpha' alphabetically, 'default' keeps "
3342
+ "insertion order. When omitted, defaults to 'usage' if usage data "
3343
+ "exists, otherwise 'default'."
3344
+ ),
3345
+ )
3346
+ pre.add_argument(
3347
+ "--top",
3348
+ type=int,
3349
+ default=None,
3350
+ metavar="N",
3351
+ help="Show only the top N tools in --list output (useful for LLM agents)",
3352
+ )
3353
+ pre.add_argument(
3354
+ "--compact",
3355
+ action="store_true",
3356
+ help="Space-separated tool names only, no descriptions (~2 tokens/tool)",
3357
+ )
3113
3358
  pre.add_argument("--pretty", action="store_true", help="Pretty-print JSON output")
3114
3359
  pre.add_argument("--raw", action="store_true", help="Print raw response body")
3115
3360
  pre.add_argument(
@@ -3521,6 +3766,7 @@ def _handle_openapi_mode(
3521
3766
  oauth_provider: "httpx.Auth | None" = None,
3522
3767
  ) -> None:
3523
3768
  """Execute OpenAPI mode: load spec, build parser, execute."""
3769
+ src_hash = _source_hash_for(pre_args.spec)
3524
3770
  spec = load_openapi_spec(
3525
3771
  pre_args.spec,
3526
3772
  auth_headers,
@@ -3535,14 +3781,20 @@ def _handle_openapi_mode(
3535
3781
  commands, bake_config.include, bake_config.exclude, bake_config.methods,
3536
3782
  )
3537
3783
 
3784
+ list_kwargs = dict(
3785
+ verbose=pre_args.verbose, compact=pre_args.compact,
3786
+ source_hash=src_hash, sort_mode=pre_args.sort_mode, top=pre_args.top,
3787
+ )
3788
+
3538
3789
  if pre_args.list_commands:
3539
3790
  if search_pattern:
3540
3791
  commands = _filter_commands(commands, search_pattern)
3541
3792
  if not commands:
3542
3793
  print(f"\nNo tools matching '{search_pattern}'.")
3543
3794
  return
3544
- print(f"\nTools matching '{search_pattern}':")
3545
- list_openapi_commands(commands, verbose=pre_args.verbose)
3795
+ if not pre_args.compact:
3796
+ print(f"\nTools matching '{search_pattern}':")
3797
+ list_openapi_commands(commands, **list_kwargs)
3546
3798
  return
3547
3799
 
3548
3800
  if not remaining:
@@ -3588,6 +3840,9 @@ def _handle_openapi_mode(
3588
3840
  jq_expr=pre_args.jq, head=pre_args.head,
3589
3841
  )
3590
3842
 
3843
+ # Record usage after successful execution
3844
+ record_usage(src_hash, cmd.tool_name or cmd.graphql_field_name or cmd.name)
3845
+
3591
3846
 
3592
3847
  def _main_impl(argv: list[str], bake_config: BakeConfig | None = None):
3593
3848
  pre = _build_main_parser()
@@ -3645,6 +3900,9 @@ def _main_impl(argv: list[str], bake_config: BakeConfig | None = None):
3645
3900
  jq_expr=pre_args.jq,
3646
3901
  head=pre_args.head,
3647
3902
  verbose=pre_args.verbose,
3903
+ sort_mode=pre_args.sort_mode,
3904
+ top=pre_args.top,
3905
+ compact=pre_args.compact,
3648
3906
  )
3649
3907
  return
3650
3908
 
@@ -3677,6 +3935,9 @@ def _main_impl(argv: list[str], bake_config: BakeConfig | None = None):
3677
3935
  jq_expr=pre_args.jq,
3678
3936
  head=pre_args.head,
3679
3937
  verbose=pre_args.verbose,
3938
+ sort_mode=pre_args.sort_mode,
3939
+ top=pre_args.top,
3940
+ compact=pre_args.compact,
3680
3941
  )
3681
3942
  return
3682
3943
 
File without changes
File without changes
File without changes