mcp2cli 3.4.0__tar.gz → 3.5.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,3 +1,22 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcp2cli
3
+ Version: 3.5.0
4
+ Summary: Turn any MCP server or OpenAPI spec into a CLI
5
+ Author: Stephan Fitzpatrick
6
+ Author-email: Stephan Fitzpatrick <stephan@knowsuchagency.com>
7
+ License-Expression: MIT
8
+ Requires-Dist: httpx
9
+ Requires-Dist: mcp>=1.26,<3
10
+ Requires-Dist: pyyaml
11
+ Requires-Dist: pytest ; extra == 'test'
12
+ Requires-Dist: pytest-asyncio ; extra == 'test'
13
+ Requires-Dist: tiktoken ; extra == 'test'
14
+ Requires-Python: >=3.10
15
+ Project-URL: Homepage, https://github.com/knowsuchagency/mcp2cli
16
+ Project-URL: Repository, https://github.com/knowsuchagency/mcp2cli
17
+ Provides-Extra: test
18
+ Description-Content-Type: text/markdown
19
+
1
20
  <p align="center">
2
21
  <img src="https://raw.githubusercontent.com/knowsuchagency/mcp2cli/main/assets/hero.png" alt="mcp2cli — one CLI for every API" width="700">
3
22
  </p>
@@ -364,13 +383,27 @@ Subcommands and their flags are generated dynamically from the spec or MCP serve
364
383
  # Install with test + MCP deps
365
384
  uv sync --extra test
366
385
 
367
- # Run tests (96 tests covering OpenAPI, MCP stdio, MCP HTTP, caching, and token savings)
386
+ # Run tests
368
387
  uv run pytest tests/ -v
369
388
 
370
389
  # Run just the token savings tests
371
390
  uv run pytest tests/test_token_savings.py -v -s
372
391
  ```
373
392
 
393
+ ### MCP SDK compatibility
394
+
395
+ mcp2cli works with **both major versions** of the MCP Python SDK (`mcp>=1.26,<3`),
396
+ so it never forces a resolver conflict with other tools in the same environment.
397
+ CI runs the suite against the declared floor, the latest 1.x, and the latest 2.x.
398
+
399
+ The two majors differ in ways that matter to a client — v2 renamed model fields
400
+ to snake_case, replaced `streamablehttp_client`, moved to `httpx2`, and dropped
401
+ the session-id element from the transport tuple. Those differences are confined
402
+ to a handful of helpers (`_mcp_attr`, `_mcp_dump`, `_streamable_streams`,
403
+ `_list_tools_page`, `_resource_uri`, `_authorization_code_result`); the rest of
404
+ the codebase is version-agnostic. The test fixtures speak the JSON-RPC wire
405
+ protocol directly and import no SDK, so they hold across majors.
406
+
374
407
  ---
375
408
 
376
409
  ## License
@@ -1,22 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: mcp2cli
3
- Version: 3.4.0
4
- Summary: Turn any MCP server or OpenAPI spec into a CLI
5
- Author: Stephan Fitzpatrick
6
- Author-email: Stephan Fitzpatrick <stephan@knowsuchagency.com>
7
- License-Expression: MIT
8
- Requires-Dist: httpx
9
- Requires-Dist: mcp>=1.0,<2
10
- Requires-Dist: pyyaml
11
- Requires-Dist: pytest ; extra == 'test'
12
- Requires-Dist: pytest-asyncio ; extra == 'test'
13
- Requires-Dist: tiktoken ; extra == 'test'
14
- Requires-Python: >=3.10
15
- Project-URL: Homepage, https://github.com/knowsuchagency/mcp2cli
16
- Project-URL: Repository, https://github.com/knowsuchagency/mcp2cli
17
- Provides-Extra: test
18
- Description-Content-Type: text/markdown
19
-
20
1
  <p align="center">
21
2
  <img src="https://raw.githubusercontent.com/knowsuchagency/mcp2cli/main/assets/hero.png" alt="mcp2cli — one CLI for every API" width="700">
22
3
  </p>
@@ -383,13 +364,27 @@ Subcommands and their flags are generated dynamically from the spec or MCP serve
383
364
  # Install with test + MCP deps
384
365
  uv sync --extra test
385
366
 
386
- # Run tests (96 tests covering OpenAPI, MCP stdio, MCP HTTP, caching, and token savings)
367
+ # Run tests
387
368
  uv run pytest tests/ -v
388
369
 
389
370
  # Run just the token savings tests
390
371
  uv run pytest tests/test_token_savings.py -v -s
391
372
  ```
392
373
 
374
+ ### MCP SDK compatibility
375
+
376
+ mcp2cli works with **both major versions** of the MCP Python SDK (`mcp>=1.26,<3`),
377
+ so it never forces a resolver conflict with other tools in the same environment.
378
+ CI runs the suite against the declared floor, the latest 1.x, and the latest 2.x.
379
+
380
+ The two majors differ in ways that matter to a client — v2 renamed model fields
381
+ to snake_case, replaced `streamablehttp_client`, moved to `httpx2`, and dropped
382
+ the session-id element from the transport tuple. Those differences are confined
383
+ to a handful of helpers (`_mcp_attr`, `_mcp_dump`, `_streamable_streams`,
384
+ `_list_tools_page`, `_resource_uri`, `_authorization_code_result`); the rest of
385
+ the codebase is version-agnostic. The test fixtures speak the JSON-RPC wire
386
+ protocol directly and import no SDK, so they hold across majors.
387
+
393
388
  ---
394
389
 
395
390
  ## License
@@ -1,13 +1,13 @@
1
1
  [project]
2
2
  name = "mcp2cli"
3
- version = "3.4.0"
3
+ version = "3.5.0"
4
4
  description = "Turn any MCP server or OpenAPI spec into a CLI"
5
5
  readme = "README.md"
6
6
  license = "MIT"
7
7
  requires-python = ">=3.10"
8
8
  dependencies = [
9
9
  "httpx",
10
- "mcp>=1.0,<2",
10
+ "mcp>=1.26,<3",
11
11
  "pyyaml",
12
12
  ]
13
13
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mcp2cli"
3
- version = "3.4.0"
3
+ version = "3.5.0"
4
4
  description = "Turn any MCP server or OpenAPI spec into a CLI"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -10,7 +10,9 @@ authors = [
10
10
  requires-python = ">=3.10"
11
11
  dependencies = [
12
12
  "httpx",
13
- "mcp>=1.0,<2",
13
+ # Works on both SDK majors; 1.26 is the floor we verify in CI, and the one
14
+ # where `streamable_http_client` + `create_mcp_http_client` are both present.
15
+ "mcp>=1.26,<3",
14
16
  "pyyaml",
15
17
  ]
16
18
 
@@ -23,6 +23,7 @@ import sys
23
23
  import threading
24
24
  import time
25
25
  import webbrowser
26
+ from contextlib import asynccontextmanager
26
27
  from dataclasses import dataclass, field
27
28
  from http.server import HTTPServer, BaseHTTPRequestHandler
28
29
  from pathlib import Path
@@ -281,6 +282,125 @@ def _toon_encode(json_str: str) -> str | None:
281
282
  return None
282
283
 
283
284
 
285
+ # ---------------------------------------------------------------------------
286
+ # MCP SDK compatibility (v1 and v2)
287
+ # ---------------------------------------------------------------------------
288
+
289
+ # SDK 2.0 renamed model fields from camelCase to snake_case, keeping camelCase
290
+ # only as serialization aliases -- so attribute access has to use the new name
291
+ # while the wire format is unchanged. A name missing from this map is an
292
+ # unhandled rename and raises KeyError rather than silently reading None.
293
+ _MCP_RENAMED_FIELDS = {
294
+ "inputSchema": "input_schema",
295
+ "outputSchema": "output_schema",
296
+ "nextCursor": "next_cursor",
297
+ "resourceTemplates": "resource_templates",
298
+ "uriTemplate": "uri_template",
299
+ "mimeType": "mime_type",
300
+ "structuredContent": "structured_content",
301
+ "isError": "is_error",
302
+ }
303
+
304
+
305
+ def _mcp_attr(obj, name: str):
306
+ """Read an SDK model field across the v1/v2 camelCase -> snake_case rename."""
307
+ try:
308
+ return getattr(obj, name)
309
+ except AttributeError:
310
+ return getattr(obj, _MCP_RENAMED_FIELDS[name])
311
+
312
+
313
+ def _demote_meta_alias(node: dict) -> None:
314
+ """Emit the SDK's ``_meta`` alias as ``meta``, the spelling mcp2cli ships."""
315
+ if "_meta" in node:
316
+ node["meta"] = node.pop("_meta")
317
+
318
+
319
+ def _mcp_dump(model) -> dict:
320
+ """Serialize an SDK model using the camelCase wire names on either major.
321
+
322
+ v2 renamed model attributes to snake_case, so a plain ``model_dump()``
323
+ would silently change mcp2cli's ``--json`` envelope from ``isError`` to
324
+ ``is_error`` depending on which SDK happened to be installed. ``by_alias``
325
+ pins the wire spelling on both.
326
+
327
+ ``meta`` is aliased to ``_meta`` in both majors, and mcp2cli has always
328
+ emitted it as ``meta``, so it is mapped back -- but only on the envelope
329
+ and its content items, which the SDK owns. ``structuredContent`` is the
330
+ tool's own payload and is never rewritten, so a tool that legitimately
331
+ returns a ``_meta`` key keeps it.
332
+ """
333
+ data = model.model_dump(mode="json", by_alias=True)
334
+ _demote_meta_alias(data)
335
+ for item in data.get("content") or ():
336
+ if isinstance(item, dict):
337
+ _demote_meta_alias(item)
338
+ return data
339
+
340
+
341
+ def _resource_uri(uri: str):
342
+ """Coerce a resource URI to what ``resources/read`` expects.
343
+
344
+ v1 types the request param as a pydantic ``AnyUrl``; v2 takes a plain
345
+ string and rejects an ``AnyUrl``.
346
+ """
347
+ from mcp.types import ReadResourceRequestParams
348
+
349
+ if ReadResourceRequestParams.model_fields["uri"].annotation is str:
350
+ return uri
351
+ from pydantic import AnyUrl
352
+
353
+ return AnyUrl(uri)
354
+
355
+
356
+ @asynccontextmanager
357
+ async def _streamable_streams(url: str, headers=None, auth=None):
358
+ """Open a streamable-http transport, yielding ``(read, write)``.
359
+
360
+ Three things differ across SDK majors here, which is why this is the only
361
+ place that talks to that transport (issues #68, #74):
362
+
363
+ * v2 dropped the ``streamablehttp_client`` alias, keeping only
364
+ ``streamable_http_client`` -- the original break.
365
+ * that surviving function takes a pre-built ``http_client`` instead of
366
+ ``headers``/``auth``, and v2 is built on **httpx2**, not httpx, so the
367
+ client has to come from the SDK's own factory to be the right flavour.
368
+ * v1 yields a third element (a get-session-id callback) that v2 dropped.
369
+ mcp2cli never used it, so both shapes collapse to ``(read, write)``.
370
+ """
371
+ from mcp.client.streamable_http import streamable_http_client
372
+ from mcp.shared._httpx_utils import create_mcp_http_client
373
+
374
+ async with create_mcp_http_client(headers=headers, auth=auth) as client:
375
+ async with streamable_http_client(url, http_client=client) as streams:
376
+ yield streams[0], streams[1]
377
+
378
+
379
+ async def _list_tools_page(session, cursor: str | None):
380
+ """Request one page of ``tools/list``.
381
+
382
+ Both majors accept ``params``; v1's ``cursor=`` shorthand is deprecated
383
+ there and gone in v2, so this is the one spelling that works on both.
384
+ """
385
+ from mcp.types import PaginatedRequestParams
386
+
387
+ params = PaginatedRequestParams(cursor=cursor) if cursor else None
388
+ return await session.list_tools(params=params)
389
+
390
+
391
+ def _authorization_code_result(code: str, state: str | None):
392
+ """Wrap a callback result in whatever ``callback_handler`` must return.
393
+
394
+ v1 expects a plain ``(code, state)`` tuple; v2 expects an
395
+ ``AuthorizationCodeResult`` model.
396
+ """
397
+ try:
398
+ from mcp.shared.auth import AuthorizationCodeResult
399
+ except ImportError:
400
+ return (code, state)
401
+ return AuthorizationCodeResult(code=code, state=state)
402
+
403
+
284
404
  def _ensure_utf8_output() -> None:
285
405
  """Make non-ASCII output safe on consoles that cannot encode it.
286
406
 
@@ -877,12 +997,21 @@ def build_oauth_provider(
877
997
  await super()._initialize()
878
998
  _restore_token_expiry_from_sidecar(self.context)
879
999
 
1000
+ # v2 renamed the `scopes` argument to `scope`.
1001
+ import inspect
1002
+
1003
+ scope_kwarg = (
1004
+ "scope"
1005
+ if "scope"
1006
+ in inspect.signature(ClientCredentialsOAuthProvider.__init__).parameters
1007
+ else "scopes"
1008
+ )
880
1009
  return _RobustClientCredentialsProvider(
881
1010
  server_url=server_url,
882
1011
  storage=storage,
883
1012
  client_id=client_id,
884
1013
  client_secret=client_secret,
885
- scopes=scope,
1014
+ **{scope_kwarg: scope},
886
1015
  )
887
1016
 
888
1017
  from mcp.client.auth.oauth2 import OAuthClientProvider
@@ -1105,8 +1234,9 @@ def build_oauth_provider(
1105
1234
  file=sys.stderr,
1106
1235
  )
1107
1236
 
1108
- async def callback_handler() -> tuple[str, str | None]:
1109
- return await anyio.to_thread.run_sync(_prompt_oauth_callback)
1237
+ async def callback_handler():
1238
+ code, state = await anyio.to_thread.run_sync(_prompt_oauth_callback)
1239
+ return _authorization_code_result(code, state)
1110
1240
  else:
1111
1241
  # Reset callback handler state
1112
1242
  _CallbackHandler.auth_code = None
@@ -1129,7 +1259,7 @@ def build_oauth_provider(
1129
1259
  print(f"If browser doesn't open, visit: {auth_url}", file=sys.stderr)
1130
1260
  webbrowser.open(auth_url)
1131
1261
 
1132
- async def callback_handler() -> tuple[str, str | None]:
1262
+ async def callback_handler():
1133
1263
  # Run the HTTP server in a thread, wait for the callback
1134
1264
  thread = threading.Thread(target=server.handle_request, daemon=True)
1135
1265
  thread.start()
@@ -1142,7 +1272,9 @@ def build_oauth_provider(
1142
1272
  raise RuntimeError(f"OAuth error: {_CallbackHandler.error}")
1143
1273
  if not _CallbackHandler.auth_code:
1144
1274
  raise RuntimeError("No authorization code received")
1145
- return (_CallbackHandler.auth_code, _CallbackHandler.state)
1275
+ return _authorization_code_result(
1276
+ _CallbackHandler.auth_code, _CallbackHandler.state
1277
+ )
1146
1278
 
1147
1279
  return _RobustOAuthClientProvider(
1148
1280
  server_url=server_url,
@@ -2681,11 +2813,9 @@ def run_mcp_http(
2681
2813
  headers = dict(auth_headers) if auth_headers else None
2682
2814
 
2683
2815
  async def _with_streamable():
2684
- from mcp.client.streamable_http import streamablehttp_client
2685
-
2686
- async with streamablehttp_client(
2816
+ async with _streamable_streams(
2687
2817
  url, headers=headers, auth=oauth_provider
2688
- ) as (read, write, _):
2818
+ ) as (read, write):
2689
2819
  async with ClientSession(read, write) as session:
2690
2820
  await session.initialize()
2691
2821
  return await _mcp_session(
@@ -2863,7 +2993,7 @@ async def _mcp_session(
2863
2993
  {
2864
2994
  "name": t.name,
2865
2995
  "description": t.description or "",
2866
- "inputSchema": t.inputSchema or {},
2996
+ "inputSchema": _mcp_attr(t, "inputSchema") or {},
2867
2997
  }
2868
2998
  for t in all_tools
2869
2999
  ]
@@ -2895,8 +3025,9 @@ async def _mcp_session(
2895
3025
 
2896
3026
  if json_output:
2897
3027
  # Emit the full MCP CallToolResult envelope (content, structuredContent,
2898
- # isError) using the SDK's own serializer — 100% MCP-compatible.
2899
- output_result(result.model_dump(mode="json"), pretty=pretty, head=head, json_output=True)
3028
+ # isError) with the camelCase wire names, so the envelope does not
3029
+ # change shape with the installed SDK major.
3030
+ output_result(_mcp_dump(result), pretty=pretty, head=head, json_output=True)
2900
3031
  return
2901
3032
 
2902
3033
  text = _extract_content_parts(result.content)
@@ -2926,7 +3057,7 @@ async def _handle_resources(
2926
3057
  "name": r.name,
2927
3058
  "uri": str(r.uri),
2928
3059
  "description": r.description or "",
2929
- "mimeType": r.mimeType or "",
3060
+ "mimeType": _mcp_attr(r, "mimeType") or "",
2930
3061
  }
2931
3062
  for r in result.resources
2932
3063
  ]
@@ -2936,17 +3067,15 @@ async def _handle_resources(
2936
3067
  data = [
2937
3068
  {
2938
3069
  "name": t.name,
2939
- "uriTemplate": str(t.uriTemplate),
3070
+ "uriTemplate": str(_mcp_attr(t, "uriTemplate")),
2940
3071
  "description": t.description or "",
2941
- "mimeType": t.mimeType or "",
3072
+ "mimeType": _mcp_attr(t, "mimeType") or "",
2942
3073
  }
2943
- for t in result.resourceTemplates
3074
+ for t in _mcp_attr(result, "resourceTemplates")
2944
3075
  ]
2945
3076
  output_result(data, **_out)
2946
3077
  elif action == "read":
2947
- from pydantic import AnyUrl
2948
-
2949
- result = await session.read_resource(AnyUrl(uri))
3078
+ result = await session.read_resource(_resource_uri(uri))
2950
3079
  parts = []
2951
3080
  for content in result.contents:
2952
3081
  if hasattr(content, "text"):
@@ -3001,7 +3130,7 @@ async def _handle_prompts(
3001
3130
  messages.append({"role": msg.role, "content": content.text})
3002
3131
  else:
3003
3132
  messages.append(
3004
- {"role": msg.role, "content": json.dumps(content.model_dump())}
3133
+ {"role": msg.role, "content": json.dumps(_mcp_dump(content))}
3005
3134
  )
3006
3135
  data = {"description": result.description or "", "messages": messages}
3007
3136
  output_result(data, **_out)
@@ -3170,9 +3299,9 @@ async def _list_all_tools(session):
3170
3299
  tools = []
3171
3300
  cursor = None
3172
3301
  while True:
3173
- result = await session.list_tools(cursor=cursor)
3302
+ result = await _list_tools_page(session, cursor)
3174
3303
  tools.extend(result.tools)
3175
- cursor = result.nextCursor
3304
+ cursor = _mcp_attr(result, "nextCursor")
3176
3305
  if not cursor:
3177
3306
  return tools
3178
3307
 
@@ -3180,7 +3309,11 @@ async def _list_all_tools(session):
3180
3309
  async def _dispatch_list_tools(session, params):
3181
3310
  tools = await _list_all_tools(session)
3182
3311
  return [
3183
- {"name": t.name, "description": t.description or "", "inputSchema": t.inputSchema or {}}
3312
+ {
3313
+ "name": t.name,
3314
+ "description": t.description or "",
3315
+ "inputSchema": _mcp_attr(t, "inputSchema") or {},
3316
+ }
3184
3317
  for t in tools
3185
3318
  ]
3186
3319
 
@@ -3193,23 +3326,31 @@ async def _dispatch_call_tool(session, params):
3193
3326
  async def _dispatch_list_resources(session, params):
3194
3327
  result = await session.list_resources()
3195
3328
  return [
3196
- {"name": r.name, "uri": str(r.uri), "description": r.description or "", "mimeType": r.mimeType or ""}
3329
+ {
3330
+ "name": r.name,
3331
+ "uri": str(r.uri),
3332
+ "description": r.description or "",
3333
+ "mimeType": _mcp_attr(r, "mimeType") or "",
3334
+ }
3197
3335
  for r in result.resources
3198
3336
  ]
3199
3337
 
3200
3338
 
3201
3339
  async def _dispatch_read_resource(session, params):
3202
- from pydantic import AnyUrl
3203
-
3204
- result = await session.read_resource(AnyUrl(params["uri"]))
3340
+ result = await session.read_resource(_resource_uri(params["uri"]))
3205
3341
  return _extract_content_parts(result.contents, attrs=("text", "blob"))
3206
3342
 
3207
3343
 
3208
3344
  async def _dispatch_list_resource_templates(session, params):
3209
3345
  result = await session.list_resource_templates()
3210
3346
  return [
3211
- {"name": t.name, "uriTemplate": str(t.uriTemplate), "description": t.description or "", "mimeType": t.mimeType or ""}
3212
- for t in result.resourceTemplates
3347
+ {
3348
+ "name": t.name,
3349
+ "uriTemplate": str(_mcp_attr(t, "uriTemplate")),
3350
+ "description": t.description or "",
3351
+ "mimeType": _mcp_attr(t, "mimeType") or "",
3352
+ }
3353
+ for t in _mcp_attr(result, "resourceTemplates")
3213
3354
  ]
3214
3355
 
3215
3356
 
@@ -3236,7 +3377,9 @@ async def _dispatch_get_prompt(session, params):
3236
3377
  if hasattr(content, "text"):
3237
3378
  messages.append({"role": msg.role, "content": content.text})
3238
3379
  else:
3239
- messages.append({"role": msg.role, "content": json.dumps(content.model_dump())})
3380
+ messages.append(
3381
+ {"role": msg.role, "content": json.dumps(_mcp_dump(content))}
3382
+ )
3240
3383
  return {"description": result.description or "", "messages": messages}
3241
3384
 
3242
3385
 
@@ -3386,12 +3529,9 @@ def _run_session_daemon(config_json: str):
3386
3529
  headers = dict(auth_headers) if auth_headers else None
3387
3530
 
3388
3531
  async def _via_streamable():
3389
- from mcp.client.streamable_http import streamablehttp_client
3390
-
3391
- async with streamablehttp_client(source, headers=headers) as (
3532
+ async with _streamable_streams(source, headers=headers) as (
3392
3533
  read,
3393
3534
  write,
3394
- _,
3395
3535
  ):
3396
3536
  async with ClientSession(read, write) as session:
3397
3537
  await _run_with_session(session)
@@ -3668,7 +3808,7 @@ def _fetch_mcp_tools(
3668
3808
  {
3669
3809
  "name": t.name,
3670
3810
  "description": t.description or "",
3671
- "inputSchema": t.inputSchema or {},
3811
+ "inputSchema": _mcp_attr(t, "inputSchema") or {},
3672
3812
  }
3673
3813
  for t in all_tools
3674
3814
  )
@@ -3693,11 +3833,9 @@ def _fetch_mcp_tools(
3693
3833
  headers = dict(auth_headers) if auth_headers else None
3694
3834
 
3695
3835
  async def _via_streamable():
3696
- from mcp.client.streamable_http import streamablehttp_client
3697
-
3698
- async with streamablehttp_client(
3836
+ async with _streamable_streams(
3699
3837
  source, headers=headers, auth=oauth_provider
3700
- ) as (read, write, _):
3838
+ ) as (read, write):
3701
3839
  async with ClientSession(read, write) as session:
3702
3840
  await session.initialize()
3703
3841
  await _extract_tools(session)
File without changes
File without changes