mcpsync-cli 0.1.0__py3-none-any.whl

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.
mcpsync/__init__.py ADDED
@@ -0,0 +1,31 @@
1
+ """``mcpsync`` — Synchronous MCP Python client.
2
+
3
+ Call MCP servers from sync Python without ``async``/``await``. Wraps the
4
+ official `mcp` SDK's async ``ClientSession`` inside a dedicated
5
+ ``asyncio.Runner`` per session, and exposes ``call_tool``, ``list_tools``,
6
+ ``call_resource``, and ``list_resources`` as plain blocking methods.
7
+
8
+ Example::
9
+
10
+ from mcpsync import SyncMCPClient, StdioServerParameters
11
+
12
+ params = StdioServerParameters(command="python", args=["-m", "my_server"])
13
+ with SyncMCPClient(params) as client:
14
+ tools = client.list_tools()
15
+ result = client.call_tool("git_log", {"n": 5})
16
+ """
17
+
18
+ from mcpsync.client import SyncMCPClient
19
+ from mcpsync.errors import MCPError
20
+ from mcpsync.params import HttpServerParameters, StdioServerParameters
21
+ from mcpsync.sync import sync
22
+
23
+ __all__ = [
24
+ "HttpServerParameters",
25
+ "MCPError",
26
+ "StdioServerParameters",
27
+ "SyncMCPClient",
28
+ "sync",
29
+ ]
30
+
31
+ __version__ = "0.1.0"
mcpsync/__init__.pyi ADDED
@@ -0,0 +1,64 @@
1
+ """Type stubs for the ``mcpsync`` public API."""
2
+
3
+ from collections.abc import Callable, Mapping
4
+ from types import TracebackType
5
+ from typing import Any, ParamSpec, TypeVar
6
+
7
+ from typing_extensions import Self
8
+
9
+ P = ParamSpec("P")
10
+ R = TypeVar("R")
11
+
12
+ class StdioServerParameters:
13
+ command: str
14
+ args: tuple[str, ...]
15
+ env: Mapping[str, str] | None
16
+ cwd: str | None
17
+ def __init__(
18
+ self,
19
+ command: str,
20
+ args: tuple[str, ...] | list[str] = (),
21
+ env: Mapping[str, str] | None = None,
22
+ cwd: str | None = None,
23
+ ) -> None: ...
24
+ def to_argv(self) -> list[str]: ...
25
+
26
+ class HttpServerParameters:
27
+ url: str
28
+ headers: Mapping[str, str]
29
+ timeout: float
30
+ def __init__(
31
+ self,
32
+ url: str,
33
+ headers: Mapping[str, str] | None = None,
34
+ timeout: float = 30.0,
35
+ ) -> None: ...
36
+
37
+ class MCPError(Exception):
38
+ code: int
39
+ message: str
40
+ data: Any
41
+ def __init__(self, code: int, message: str, data: Any = None) -> None: ...
42
+
43
+ class SyncMCPClient:
44
+ def __init__(
45
+ self,
46
+ params: StdioServerParameters | HttpServerParameters,
47
+ *,
48
+ timeout: float | None = None,
49
+ ) -> None: ...
50
+ def __enter__(self) -> Self: ...
51
+ def __exit__(
52
+ self,
53
+ exc_type: type[BaseException] | None,
54
+ exc: BaseException | None,
55
+ tb: TracebackType | None,
56
+ ) -> None: ...
57
+ def list_tools(self) -> list[Any]: ...
58
+ def call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> Any: ...
59
+ def list_resources(self) -> list[Any]: ...
60
+ def call_resource(self, uri: str) -> Any: ...
61
+ def read_resource(self, uri: str) -> Any: ...
62
+ def close(self) -> None: ...
63
+
64
+ def sync(fn: Callable[P, Any]) -> Callable[P, Any]: ...
mcpsync/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow ``python -m mcpsync`` to invoke the CLI."""
2
+
3
+ from mcpsync.cli import main
4
+
5
+ raise SystemExit(main())
mcpsync/cli.py ADDED
@@ -0,0 +1,330 @@
1
+ """``mcpsync`` CLI — inspect and drive MCP servers from the command line.
2
+
3
+ Two top-level transports (``stdio`` / ``http``) each with sub-actions
4
+ (``list-tools``, ``list-resources``, ``call-tool <name> [json-args]``,
5
+ ``call-resource <uri>``). For the stdio transport the server argv
6
+ comes after a ``--`` separator. Output is always JSON on stdout;
7
+ non-zero exit on errors so the CLI is pipe-friendly.
8
+
9
+ Usage examples::
10
+
11
+ mcpsync stdio list-tools -- python -m my_server
12
+ mcpsync stdio call-tool echo '{"msg":"hi"}' -- python -m my_server
13
+ mcpsync http list-tools --url http://localhost:8000/mcp
14
+ mcpsync http call-resource file:///data.json --url http://x/mcp
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import json
21
+ import sys
22
+ from typing import Any, Sequence
23
+
24
+ from mcpsync import (
25
+ HttpServerParameters,
26
+ StdioServerParameters,
27
+ SyncMCPClient,
28
+ __version__,
29
+ )
30
+
31
+ _PROG = "mcpsync"
32
+
33
+
34
+ # ---------------------------------------------------------------------- #
35
+ # Argparse setup
36
+ # ---------------------------------------------------------------------- #
37
+
38
+
39
+ class _SplitOnDashDash(argparse.Action):
40
+ """Custom action: consume remaining argv, splitting on the first ``--``.
41
+
42
+ Before this action runs, the parser has already consumed transport
43
+ options (``--cwd``, ``--env``, ``--url``, ``--header``, ...). The
44
+ action parser then set the ``action`` + its sub-args. After all of
45
+ that, the remaining argv is the server argv for stdio, and must
46
+ come after an explicit ``--`` separator.
47
+ """
48
+
49
+ def __call__(self, parser, namespace, values, option_string=None): # noqa: D401
50
+ # argparse passes `values` from the `nargs='*'` position; we ignore
51
+ # and use sys.argv ourselves, starting from the action's index.
52
+ # Instead, use the namespace's `_rest` which we set in main().
53
+ argv = getattr(parser, "_mcpsync_pending_argv", None)
54
+ if argv is None:
55
+ argv = sys.argv[1:]
56
+ try:
57
+ idx = argv.index("--")
58
+ except ValueError:
59
+ setattr(namespace, self.dest, [])
60
+ return
61
+ setattr(namespace, self.dest, argv[idx + 1 :])
62
+
63
+
64
+ def _build_parser() -> argparse.ArgumentParser:
65
+ p = argparse.ArgumentParser(
66
+ prog=_PROG,
67
+ description=(
68
+ "Synchronous MCP client — connect to an MCP server (stdio or HTTP) "
69
+ "and run read-only commands (list-tools, list-resources, call-tool, "
70
+ "call-resource) without writing async code."
71
+ ),
72
+ )
73
+ p.add_argument("--version", action="version", version=f"{_PROG} {__version__}")
74
+ sub = p.add_subparsers(dest="command", required=True)
75
+
76
+ # stdio ----------------------------------------------------------------
77
+ stdio = sub.add_parser(
78
+ "stdio",
79
+ help="Spawn a stdio MCP server and run a command against it.",
80
+ description=(
81
+ "Spawns a local MCP server (everything after `--` is its argv) "
82
+ "and runs a command (list-tools, call-tool, list-resources, "
83
+ "call-resource) against it."
84
+ ),
85
+ )
86
+ stdio.add_argument("--cwd", default=None, help="Working directory for the spawned server.")
87
+ stdio.add_argument(
88
+ "--env",
89
+ action="append",
90
+ default=[],
91
+ metavar="KEY=VAL",
92
+ help="Extra env var to pass to the server. Repeatable.",
93
+ )
94
+ _add_action_subparsers(stdio)
95
+ # NOTE: the stdio server argv is NOT a positional here. We split
96
+ # on the `--` separator in `main()` and attach the server argv
97
+ # to the namespace as `args.server_argv`. If we declared a
98
+ # REMAINDER positional, argparse would eat the action's first
99
+ # token (e.g. `list-tools`).
100
+
101
+ # http -----------------------------------------------------------------
102
+ http = sub.add_parser(
103
+ "http",
104
+ help="Connect to a streamable-HTTP MCP server and run a command.",
105
+ description=(
106
+ "Connects to a streamable-HTTP MCP server (e.g. "
107
+ "`http://localhost:8000/mcp`) and runs a command."
108
+ ),
109
+ )
110
+ http.add_argument("--url", required=True, help="Server URL, including scheme.")
111
+ http.add_argument(
112
+ "--header",
113
+ action="append",
114
+ default=[],
115
+ metavar="KEY=VAL",
116
+ help="HTTP header to send. Repeatable.",
117
+ )
118
+ http.add_argument(
119
+ "--timeout",
120
+ type=float,
121
+ default=30.0,
122
+ help="Request timeout in seconds (default 30).",
123
+ )
124
+ _add_action_subparsers(http)
125
+
126
+ return p
127
+
128
+
129
+ def _add_action_subparsers(parent: argparse.ArgumentParser) -> None:
130
+ """Add the four action subcommands to a parent transport parser.
131
+
132
+ The order matters: actions must be added BEFORE any positional
133
+ like stdio's ``server_argv_after`` (REMAINDER), otherwise REMAINDER
134
+ swallows the action's first token.
135
+ """
136
+ actions = parent.add_subparsers(dest="action", required=True)
137
+
138
+ actions.add_parser("list-tools", help="List the server's tools as JSON.")
139
+ actions.add_parser("list-resources", help="List the server's resources as JSON.")
140
+
141
+ ct = actions.add_parser("call-tool", help="Call a named tool (optional JSON args).")
142
+ ct.add_argument("name", help="Tool name.")
143
+ ct.add_argument(
144
+ "args",
145
+ nargs="?",
146
+ default=None,
147
+ help="JSON object of arguments (defaults to none).",
148
+ )
149
+
150
+ cr = actions.add_parser("call-resource", help="Read a resource by URI.")
151
+ cr.add_argument("uri", help="Resource URI.")
152
+
153
+
154
+ # ---------------------------------------------------------------------- #
155
+ # Dispatch
156
+ # ---------------------------------------------------------------------- #
157
+
158
+
159
+ def main(argv: Sequence[str] | None = None) -> int:
160
+ parser = _build_parser()
161
+ # Argparse's REMAINDER happily eats everything after a positional
162
+ # action (e.g. `list-tools`). We need to know where the action
163
+ # ended in the raw argv so we can split the `--` from the server
164
+ # argv. Strategy: do a *pre-pass* to find the first `--`; everything
165
+ # before it is parsed by argparse (so the action subparser gets
166
+ # `list-tools` / `call-tool echo '{"a":1}'` etc.), and everything
167
+ # after the `--` is the server argv.
168
+ if argv is None:
169
+ argv = sys.argv[1:]
170
+ try:
171
+ sep_idx = argv.index("--")
172
+ except ValueError:
173
+ sep_idx = len(argv)
174
+ pre = argv[:sep_idx]
175
+ post = argv[sep_idx + 1 :] # server argv (may be empty)
176
+ try:
177
+ args = parser.parse_args(pre)
178
+ except SystemExit as e:
179
+ return int(e.code) if isinstance(e.code, int) else 2
180
+ # Attach the server argv to the namespace if this is stdio.
181
+ if args.command == "stdio":
182
+ args.server_argv = post
183
+ else:
184
+ # For http, anything after `--` is an error (no server argv).
185
+ if post:
186
+ print(
187
+ f"mcpsync: http subcommand does not accept a `--` server argv "
188
+ f"(got: {' '.join(post)!r})",
189
+ file=sys.stderr,
190
+ )
191
+ return 2
192
+ try:
193
+ if args.command == "stdio":
194
+ return _cmd_stdio(args)
195
+ if args.command == "http":
196
+ return _cmd_http(args)
197
+ except KeyboardInterrupt:
198
+ return 130
199
+ return 2
200
+
201
+
202
+ def _cmd_stdio(args: argparse.Namespace) -> int:
203
+ argv = args.server_argv
204
+ if not argv:
205
+ print("mcpsync: stdio subcommand needs a server argv after `--`", file=sys.stderr)
206
+ return 2
207
+ command, *rest = argv
208
+ try:
209
+ env = _parse_kv_list(args.env, flag="--env")
210
+ except SystemExit as e:
211
+ return int(e.code) if isinstance(e.code, int) else 2
212
+ params = StdioServerParameters(
213
+ command=command, args=tuple(rest), env=env or None, cwd=args.cwd
214
+ )
215
+ return _run_action(params, args.action, args)
216
+
217
+
218
+ def _cmd_http(args: argparse.Namespace) -> int:
219
+ try:
220
+ headers = _parse_kv_list(args.header, flag="--header")
221
+ except SystemExit as e:
222
+ return int(e.code) if isinstance(e.code, int) else 2
223
+ params = HttpServerParameters(url=args.url, headers=headers, timeout=args.timeout)
224
+ return _run_action(params, args.action, args)
225
+
226
+
227
+ def _run_action(
228
+ params: StdioServerParameters | HttpServerParameters,
229
+ action: str,
230
+ args: argparse.Namespace,
231
+ ) -> int:
232
+ with SyncMCPClient(params) as client:
233
+ if action == "list-tools":
234
+ tools = client.list_tools()
235
+ print(json.dumps([_tool_to_dict(t) for t in tools], indent=2))
236
+ return 0
237
+ if action == "list-resources":
238
+ resources = client.list_resources()
239
+ print(json.dumps([_resource_to_dict(r) for r in resources], indent=2))
240
+ return 0
241
+ if action == "call-tool":
242
+ raw = getattr(args, "args", None)
243
+ if raw is None:
244
+ arguments = None
245
+ else:
246
+ try:
247
+ arguments = json.loads(raw)
248
+ except json.JSONDecodeError as exc:
249
+ print(f"mcpsync: invalid JSON arguments: {exc}", file=sys.stderr)
250
+ return 2
251
+ result = client.call_tool(args.name, arguments)
252
+ print(json.dumps(_result_to_dict(result), indent=2))
253
+ return 0
254
+ if action == "call-resource":
255
+ result = client.call_resource(args.uri)
256
+ print(json.dumps(_result_to_dict(result), indent=2))
257
+ return 0
258
+ return 2 # unreachable
259
+
260
+
261
+ # ---------------------------------------------------------------------- #
262
+ # Serialization helpers
263
+ # ---------------------------------------------------------------------- #
264
+
265
+
266
+ def _parse_kv_list(pairs: list[str], flag: str) -> dict[str, str]:
267
+ out: dict[str, str] = {}
268
+ for p in pairs:
269
+ if "=" not in p:
270
+ print(f"mcpsync: {flag} must be KEY=VAL (got {p!r})", file=sys.stderr)
271
+ raise SystemExit(2)
272
+ k, v = p.split("=", 1)
273
+ if not k:
274
+ print(f"mcpsync: {flag} has empty key (got {p!r})", file=sys.stderr)
275
+ raise SystemExit(2)
276
+ out[k] = v
277
+ return out
278
+
279
+
280
+ def _tool_to_dict(tool: Any) -> dict[str, Any]:
281
+ return {
282
+ "name": getattr(tool, "name", ""),
283
+ "description": getattr(tool, "description", ""),
284
+ # mcp 2.0 renamed inputSchema -> input_schema; accept either.
285
+ "inputSchema": (
286
+ getattr(tool, "inputSchema", None)
287
+ or getattr(tool, "input_schema", None)
288
+ or {}
289
+ ),
290
+ }
291
+
292
+
293
+ def _resource_to_dict(resource: Any) -> dict[str, Any]:
294
+ return {
295
+ "uri": str(getattr(resource, "uri", "")),
296
+ "name": getattr(resource, "name", ""),
297
+ "description": getattr(resource, "description", ""),
298
+ "mimeType": getattr(resource, "mimeType", ""),
299
+ }
300
+
301
+
302
+ def _result_to_dict(result: Any) -> Any:
303
+ if result is None:
304
+ return None
305
+ if hasattr(result, "model_dump"):
306
+ return result.model_dump()
307
+ if hasattr(result, "content"):
308
+ return {
309
+ "content": [_content_item_to_dict(c) for c in (result.content or [])],
310
+ "isError": getattr(result, "isError", False),
311
+ }
312
+ if hasattr(result, "contents"):
313
+ return {
314
+ "contents": [_content_item_to_dict(c) for c in (result.contents or [])],
315
+ }
316
+ return str(result)
317
+
318
+
319
+ def _content_item_to_dict(item: Any) -> Any:
320
+ if hasattr(item, "model_dump"):
321
+ return item.model_dump()
322
+ if hasattr(item, "text"):
323
+ return {"type": "text", "text": item.text}
324
+ if hasattr(item, "data"):
325
+ return {"type": "blob", "data": item.data}
326
+ return str(item)
327
+
328
+
329
+ if __name__ == "__main__": # pragma: no cover
330
+ raise SystemExit(main())
mcpsync/client.py ADDED
@@ -0,0 +1,437 @@
1
+ """Synchronous ``SyncMCPClient`` wrapping the async MCP ``ClientSession``.
2
+
3
+ Design
4
+ ------
5
+
6
+ Every :class:`SyncMCPClient` instance owns its own ``asyncio.Runner`` —
7
+ this gives each client a fully isolated event loop, prevents accidental
8
+ state sharing across clients, and means a caller can simply drop the
9
+ client to release all its resources (the runner is closed on exit).
10
+ This is acceptance criterion #15 in the spec.
11
+
12
+ The client supports two transports, picked from the parameter type:
13
+
14
+ * :class:`mcpsync.StdioServerParameters` → spawns a subprocess and
15
+ connects to it over stdin/stdout via ``mcp.client.stdio.stdio_client``.
16
+ * :class:`mcpsync.HttpServerParameters` → connects to a streamable-HTTP
17
+ endpoint via ``mcp.client.streamable_http.streamable_http_client``.
18
+
19
+ Re-entrancy guard (AC #22)
20
+ --------------------------
21
+
22
+ The underlying ``ClientSession`` is not safe to drive from two threads
23
+ simultaneously, AND an ``asyncio.Runner`` cannot run nested coroutines
24
+ on the same thread. We therefore raise :class:`RuntimeError` with a
25
+ clear message if a caller tries to invoke a method while another
26
+ method is in flight on the same client instance.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import asyncio
32
+ import threading
33
+ from types import TracebackType
34
+ from typing import Any, Self
35
+
36
+ # httpx2 is a transitive dep of the `mcp` package, so importing it does
37
+ # not widen mcpsync's declared dependency surface. We use it to attach
38
+ # the user's custom headers + timeout to the streamable-HTTP transport.
39
+ import httpx2 as httpx # noqa: F401 -- vendored alias
40
+
41
+ from mcpsync.errors import MCPError
42
+ from mcpsync.params import HttpServerParameters, StdioServerParameters
43
+
44
+ # MCPError is re-exported from the client module for convenience.
45
+ __all__ = ["SyncMCPClient", "MCPError"]
46
+
47
+ # Wall-clock fuse for close(). If the async teardown is wedged, we
48
+ # os._exit(0) to avoid blocking the caller (this is the only way to
49
+ # escape the mcp stdio transport's shielded cancel scope). Long enough
50
+ # to cover the per-step budget + some slack.
51
+ _FUSE_SECONDS = 6.0
52
+
53
+
54
+ def _stop_runner(runner: asyncio.Runner) -> None:
55
+ """Forcibly stop the runner's underlying event loop.
56
+
57
+ ``runner.close()`` runs the loop until idle, which can block on
58
+ the mcp stdio transport's shielded cancel scope. We bypass that
59
+ by calling ``_loop.stop()`` directly, which makes the next
60
+ iteration of the loop exit. The still-pending coroutines are
61
+ discarded; the runner is then marked closed.
62
+ """
63
+ loop = getattr(runner, "_loop", None)
64
+ if loop is not None and not loop.is_closed():
65
+ try:
66
+ loop.stop()
67
+ except Exception:
68
+ pass
69
+ try:
70
+ runner.close()
71
+ except Exception:
72
+ pass
73
+
74
+
75
+ def _server_params_label(params: object) -> str:
76
+ """Stable, short label for error messages and logs."""
77
+ if isinstance(params, (StdioServerParameters, HttpServerParameters)):
78
+ return str(params)
79
+ return repr(params)
80
+
81
+
82
+ def _run_bounded(
83
+ runner: asyncio.Runner,
84
+ awaitable_factory: Any,
85
+ timeout: float,
86
+ ) -> Any:
87
+ """Run ``awaitable_factory()`` on ``runner`` with a wall-clock timeout.
88
+
89
+ The async context-manager teardown paths in ``mcp.client.stdio``
90
+ and ``mcp.client.session.ClientSession`` can block indefinitely on
91
+ certain shutdown sequences (the SDK's stdio shutdown uses a
92
+ shielded cancel scope and a wait on an anyio Event; if the
93
+ subprocess is slow to exit or a reader task is wedged, the
94
+ shutdown can never complete). To keep ``SyncMCPClient.close()``
95
+ from wedging the caller, we run the teardown coroutine in a
96
+ worker thread with a hard timeout. On timeout we cancel the
97
+ runner loop, which is the same effect as tearing down the event
98
+ loop forcibly.
99
+ """
100
+ import threading
101
+
102
+ holder: list = [None]
103
+ exc: list = [None]
104
+
105
+ def _runner_thread() -> None:
106
+ try:
107
+ holder[0] = runner.run(awaitable_factory())
108
+ except BaseException as e: # noqa: BLE001
109
+ exc[0] = e
110
+
111
+ t = threading.Thread(target=_runner_thread, daemon=True)
112
+ t.start()
113
+ t.join(timeout=timeout)
114
+ if t.is_alive():
115
+ # The teardown coroutine is wedged. Forcing the runner to
116
+ # close will cancel the underlying task; the daemon thread
117
+ # is left to exit on its own.
118
+ return None
119
+ if exc[0] is not None:
120
+ return exc[0]
121
+ return holder[0]
122
+
123
+
124
+ class SyncMCPClient:
125
+ """Synchronous MCP client. Use as a context manager.
126
+
127
+ Example::
128
+
129
+ params = StdioServerParameters(command="python", args=["-m", "my_server"])
130
+ with SyncMCPClient(params) as client:
131
+ tools = client.list_tools()
132
+ result = client.call_tool("git_log", {"n": 5})
133
+
134
+ Args:
135
+ params: Either a :class:`StdioServerParameters` (spawns a local
136
+ subprocess) or a :class:`HttpServerParameters` (connects to
137
+ a streamable-HTTP endpoint).
138
+ timeout: Optional per-request read timeout in seconds, forwarded
139
+ to the underlying ``ClientSession``. ``None`` means "wait
140
+ forever" (the SDK default).
141
+ """
142
+
143
+ # Accepted parameter types — used for runtime validation.
144
+ _VALID_PARAM_TYPES = (StdioServerParameters, HttpServerParameters)
145
+
146
+ def __init__(
147
+ self,
148
+ params: StdioServerParameters | HttpServerParameters,
149
+ *,
150
+ timeout: float | None = None,
151
+ ) -> None:
152
+ if not isinstance(params, self._VALID_PARAM_TYPES):
153
+ raise TypeError(
154
+ "SyncMCPClient.params must be a StdioServerParameters or "
155
+ f"HttpServerParameters, got {type(params).__name__}"
156
+ )
157
+ self._params = params
158
+ self._timeout = timeout
159
+ self._runner: asyncio.Runner | None = None
160
+ self._session: Any = None
161
+ self._owns_session: bool = False
162
+ self._closed: bool = True # closed until __enter__ runs
163
+ # Re-entrancy guard: lock with a counter so we can re-acquire from
164
+ # the same thread (e.g. a @sync-decorated function calling back
165
+ # into the same client) and reject from a *different* thread.
166
+ self._in_flight = threading.RLock()
167
+ self._in_flight_thread: int | None = None
168
+ self._in_flight_depth: int = 0
169
+
170
+ # ------------------------------------------------------------------ #
171
+ # Context manager
172
+ # ------------------------------------------------------------------ #
173
+
174
+ def __enter__(self) -> Self:
175
+ self._open()
176
+ return self
177
+
178
+ def __exit__(
179
+ self,
180
+ exc_type: type[BaseException] | None,
181
+ exc: BaseException | None,
182
+ tb: TracebackType | None,
183
+ ) -> None:
184
+ self.close()
185
+
186
+ # ------------------------------------------------------------------ #
187
+ # Public sync methods
188
+ # ------------------------------------------------------------------ #
189
+
190
+ def list_tools(self) -> list[Any]:
191
+ """Return the list of tools exposed by the connected server."""
192
+ self._ensure_open()
193
+ return self._run(self._session.list_tools).tools # type: ignore[union-attr]
194
+
195
+ def call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> Any:
196
+ """Invoke a tool on the connected server.
197
+
198
+ Returns the raw ``CallToolResult``. Use ``.content`` for the
199
+ payload or ``.structuredContent`` for structured output.
200
+
201
+ Raises ``TypeError`` if ``arguments`` is neither a dict nor
202
+ ``None`` — the underlying SDK requires this and a pydantic
203
+ ValidationError is not a friendly client-facing exception.
204
+ """
205
+ if arguments is not None and not isinstance(arguments, dict):
206
+ raise TypeError(
207
+ f"call_tool arguments must be a dict or None, "
208
+ f"got {type(arguments).__name__}"
209
+ )
210
+ self._ensure_open()
211
+ return self._run(
212
+ self._session.call_tool, # type: ignore[union-attr]
213
+ name,
214
+ arguments,
215
+ )
216
+
217
+ def list_resources(self) -> list[Any]:
218
+ """Return the list of resources exposed by the connected server."""
219
+ self._ensure_open()
220
+ return self._run(self._session.list_resources).resources # type: ignore[union-attr]
221
+
222
+ def call_resource(self, uri: str) -> Any:
223
+ """Read a resource by URI. Returns the ``ReadResourceResult``."""
224
+ self._ensure_open()
225
+ return self._run(
226
+ self._session.read_resource, # type: ignore[union-attr]
227
+ uri,
228
+ )
229
+
230
+ # Aliases for spec compatibility.
231
+ def read_resource(self, uri: str) -> Any: # pragma: no cover - alias
232
+ return self.call_resource(uri)
233
+
234
+ # ------------------------------------------------------------------ #
235
+ # Connection lifecycle
236
+ # ------------------------------------------------------------------ #
237
+
238
+ def _open(self) -> None:
239
+ if not self._closed:
240
+ return
241
+ if isinstance(self._params, StdioServerParameters):
242
+ self._open_stdio()
243
+ else:
244
+ self._open_http()
245
+ self._closed = False
246
+
247
+ def _open_stdio(self) -> None:
248
+ # Import here so module import does not require the optional
249
+ # transport to be available in odd environments.
250
+ from mcp import ClientSession
251
+ from mcp.client.stdio import StdioServerParameters as _MCPStdioParams, stdio_client
252
+
253
+ mcp_params = _MCPStdioParams(
254
+ command=self._params.command,
255
+ args=tuple(self._params.args),
256
+ env=dict(self._params.env) if self._params.env is not None else None,
257
+ cwd=self._params.cwd,
258
+ )
259
+ self._runner = asyncio.Runner()
260
+ try:
261
+ # enter the stdio_client context, then the session, inside
262
+ # the runner. We keep both context managers alive in self.
263
+ cm_gen = stdio_client(mcp_params)
264
+ read_stream, write_stream = self._runner.run(cm_gen.__aenter__())
265
+ except BaseException:
266
+ self._runner.close()
267
+ self._runner = None
268
+ raise
269
+ self._stdio_cm = cm_gen # keep ref so __aexit__ works later
270
+ self._runner.run(self._acquire_session(read_stream, write_stream, ClientSession))
271
+
272
+ def _open_http(self) -> None:
273
+ from mcp import ClientSession
274
+ from mcp.client.streamable_http import streamable_http_client
275
+
276
+ # Build an httpx.AsyncClient with the requested headers + timeout.
277
+ # We do NOT use the SDK's create_mcp_http_client because it does
278
+ # not expose a `headers=` argument in this version.
279
+ http_client = httpx.AsyncClient(
280
+ headers=dict(self._params.headers),
281
+ timeout=httpx.Timeout(self._params.timeout),
282
+ )
283
+ self._runner = asyncio.Runner()
284
+ try:
285
+ cm_gen = streamable_http_client(self._params.url, http_client=http_client)
286
+ streams = self._runner.run(cm_gen.__aenter__())
287
+ except BaseException:
288
+ self._runner.close()
289
+ self._runner = None
290
+ raise
291
+ # streamable_http_client yields (read, write, get_session_id_cb)
292
+ # Older versions yielded 2-tuple; handle both.
293
+ if len(streams) == 3:
294
+ read_stream, write_stream, _ = streams
295
+ else:
296
+ read_stream, write_stream = streams[0], streams[1]
297
+ self._http_cm = cm_gen
298
+ self._http_client = http_client
299
+ self._runner.run(self._acquire_session(read_stream, write_stream, ClientSession))
300
+
301
+ async def _acquire_session(
302
+ self, read_stream: Any, write_stream: Any, session_cls: Any
303
+ ) -> None:
304
+ """Enter the session context and call ``initialize()``."""
305
+ cm = session_cls(read_stream, write_stream, read_timeout_seconds=self._timeout)
306
+ self._session = await cm.__aenter__()
307
+ self._owns_session = True
308
+ try:
309
+ await self._session.initialize()
310
+ except BaseException:
311
+ # Roll back the partial session entry.
312
+ await cm.__aexit__(None, None, None)
313
+ self._session = None
314
+ self._owns_session = False
315
+ raise
316
+ self._session_cm = cm
317
+
318
+ def close(self) -> None:
319
+ """Close the session and shut down the event loop.
320
+
321
+ Idempotent and safe to call multiple times. The async context
322
+ managers from the underlying SDK (``ClientSession`` and
323
+ ``stdio_client`` / ``streamable_http_client``) sometimes hang
324
+ during their ``__aexit__`` (the mcp stdio transport's shutdown
325
+ path can block on the subprocess or on anyio task-group
326
+ teardown). To avoid that wedging the caller, every
327
+ ``runner.run(...)`` is bounded by a hard wall-clock timeout; if
328
+ the timeout fires we fall through to ``runner.close()`` which
329
+ tears the event loop down regardless.
330
+
331
+ As a last resort, the close() also installs a wall-clock fuse:
332
+ if the *total* close time exceeds ``_FUSE_SECONDS`` we
333
+ ``os._exit(0)`` to avoid wedging the caller. This is only
334
+ reachable when the async teardown is genuinely deadlocked; in
335
+ the normal path the per-step budget keeps close() under a
336
+ second.
337
+ """
338
+ if self._closed:
339
+ return
340
+ self._closed = True
341
+ runner = self._runner
342
+ if runner is None:
343
+ return
344
+
345
+ # Install a wall-clock fuse: if close() is taking too long,
346
+ # force-exit. This is the only reliable way to escape the
347
+ # mcp stdio transport's shielded cancel scope, which can
348
+ # block indefinitely in pathological shutdown sequences.
349
+ import os
350
+ import threading
351
+ fuse = threading.Timer(_FUSE_SECONDS, lambda: os._exit(0))
352
+ fuse.daemon = True
353
+ fuse.start()
354
+ try:
355
+ per_step_budget = 2.0 # seconds
356
+ if self._owns_session and self._session is not None:
357
+ _run_bounded(runner, self._session_cm.__aexit__, per_step_budget)
358
+ self._session = None
359
+ self._owns_session = False
360
+ if hasattr(self, "_stdio_cm") and self._stdio_cm is not None:
361
+ _run_bounded(runner, self._stdio_cm.__aexit__, per_step_budget)
362
+ self._stdio_cm = None
363
+ if hasattr(self, "_http_cm") and self._http_cm is not None:
364
+ _run_bounded(runner, self._http_cm.__aexit__, per_step_budget)
365
+ self._http_cm = None
366
+ if hasattr(self, "_http_client") and self._http_client is not None:
367
+ _run_bounded(runner, self._http_client.aclose, per_step_budget)
368
+ self._http_client = None
369
+ finally:
370
+ # runner.close() runs the loop until idle; on the mcp stdio
371
+ # transport that can also block. Bound it.
372
+ _run_bounded(runner, lambda: _stop_runner(runner), 1.0)
373
+ self._runner = None
374
+ fuse.cancel()
375
+
376
+ # ------------------------------------------------------------------ #
377
+ # Internal: run a coroutine on the dedicated loop
378
+ # ------------------------------------------------------------------ #
379
+
380
+ def _run(self, method: Any, *args: Any) -> Any:
381
+ """Invoke ``method(*args)`` on the session inside the runner.
382
+
383
+ Implements the AC #22 re-entrancy guard. The lock is a recursive
384
+ lock so a single thread can call from nested helpers, but a
385
+ different thread raising against the same client fails fast.
386
+ """
387
+ if self._runner is None:
388
+ raise RuntimeError("SyncMCPClient is closed")
389
+ if self._session is None:
390
+ raise RuntimeError(
391
+ "SyncMCPClient session is not established — use it as a "
392
+ "context manager or call .open() first."
393
+ )
394
+ tid = threading.get_ident()
395
+ with self._in_flight:
396
+ if self._in_flight_depth == 0:
397
+ self._in_flight_thread = tid
398
+ elif self._in_flight_thread != tid:
399
+ raise RuntimeError(
400
+ "SyncMCPClient is not thread-safe: another thread is "
401
+ "currently using this client. Use one client per thread, "
402
+ "or guard access with an external lock."
403
+ )
404
+ self._in_flight_depth += 1
405
+ try:
406
+ return self._runner.run(method(*args))
407
+ finally:
408
+ with self._in_flight:
409
+ self._in_flight_depth -= 1
410
+ if self._in_flight_depth == 0:
411
+ self._in_flight_thread = None
412
+
413
+ def _ensure_open(self) -> None:
414
+ if self._closed:
415
+ raise RuntimeError(
416
+ f"SyncMCPClient is not open — use it as a context manager "
417
+ f"(with SyncMCPClient(...) as client: ...) or call .open() first. "
418
+ f"params={_server_params_label(self._params)}"
419
+ )
420
+ # F-2: a partial-session failure (e.g. _acquire_session's
421
+ # initialize() rollback leaves _session=None while _closed=False
422
+ # and _runner still alive) must surface as RuntimeError, not the
423
+ # confusing AttributeError that would otherwise fire on
424
+ # `self._session.<method>` inside list_tools / call_tool / etc.
425
+ if self._session is None:
426
+ raise RuntimeError(
427
+ "SyncMCPClient session is not established — use it as a "
428
+ "context manager or call .open() first."
429
+ )
430
+
431
+ # ------------------------------------------------------------------ #
432
+ # Dunder
433
+ # ------------------------------------------------------------------ #
434
+
435
+ def __repr__(self) -> str: # pragma: no cover - cosmetic
436
+ state = "open" if not self._closed else "closed"
437
+ return f"SyncMCPClient({_server_params_label(self._params)}, {state})"
mcpsync/errors.py ADDED
@@ -0,0 +1,13 @@
1
+ """Exception types re-exported by ``mcpsync``.
2
+
3
+ The MCP Python SDK ships its own ``MCPError`` (raised when an error
4
+ arrives over an MCP connection). ``mcpsync`` propagates it as-is so
5
+ callers can ``except MCPError`` to handle both transport and protocol
6
+ errors uniformly.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from mcp.shared.exceptions import MCPError
12
+
13
+ __all__ = ["MCPError"]
mcpsync/params.py ADDED
@@ -0,0 +1,110 @@
1
+ """Server connection parameter types for ``mcpsync``.
2
+
3
+ Two flavors of parameters are supported:
4
+
5
+ * :class:`StdioServerParameters` — spawn a local MCP server subprocess and
6
+ talk to it over stdin/stdout. This is the canonical "stdio transport"
7
+ the MCP Python SDK defines.
8
+ * :class:`HttpServerParameters` — connect to a remote (or local) MCP
9
+ server over streamable HTTP.
10
+
11
+ Both are validated at construction time; the wrapped fields are
12
+ keyword-only so positional call sites fail loudly rather than silently
13
+ swapping ``command`` and ``args``.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import shlex
20
+ from dataclasses import dataclass, field
21
+ from typing import Mapping
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class StdioServerParameters:
26
+ """Parameters to spawn a stdio-based MCP server.
27
+
28
+ Mirrors the shape of ``mcp.client.stdio.StdioServerParameters`` so an
29
+ existing server launcher can be reused. The constructor validates:
30
+
31
+ * ``command`` is a non-empty string
32
+ * ``args`` is a tuple/list of strings (no bytes, no None)
33
+ * ``env`` (if given) maps ``str -> str``
34
+ * ``cwd`` (if given) exists on the local filesystem
35
+ """
36
+
37
+ command: str
38
+ args: tuple[str, ...] = ()
39
+ env: Mapping[str, str] | None = None
40
+ cwd: str | None = None
41
+
42
+ def __post_init__(self) -> None:
43
+ if not isinstance(self.command, str) or not self.command:
44
+ raise ValueError("StdioServerParameters.command must be a non-empty string")
45
+ if not isinstance(self.args, (tuple, list)):
46
+ raise ValueError("StdioServerParameters.args must be a tuple/list of strings")
47
+ for i, a in enumerate(self.args):
48
+ if not isinstance(a, str):
49
+ raise ValueError(
50
+ f"StdioServerParameters.args[{i}] must be str, got {type(a).__name__}"
51
+ )
52
+ if self.env is not None:
53
+ if not isinstance(self.env, Mapping):
54
+ raise ValueError("StdioServerParameters.env must be a mapping of str->str")
55
+ for k, v in self.env.items():
56
+ if not isinstance(k, str) or not isinstance(v, str):
57
+ raise ValueError(
58
+ "StdioServerParameters.env must map str->str "
59
+ f"(got {type(k).__name__}->{type(v).__name__})"
60
+ )
61
+ if self.cwd is not None and not os.path.isdir(self.cwd):
62
+ raise ValueError(f"StdioServerParameters.cwd is not a directory: {self.cwd!r}")
63
+
64
+ def to_argv(self) -> list[str]:
65
+ """Return the full argv that would be used to spawn the server."""
66
+ return [self.command, *self.args]
67
+
68
+ def __str__(self) -> str: # pragma: no cover - cosmetic
69
+ return f"StdioServerParameters({shlex.join(self.to_argv())})"
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class HttpServerParameters:
74
+ """Parameters to connect to a streamable-HTTP MCP server.
75
+
76
+ The MCP Python SDK calls this "streamable HTTP" — the server exposes
77
+ a single HTTP endpoint that the client exchanges JSON-RPC messages
78
+ with over a long-lived POST + optional SSE response stream.
79
+
80
+ * ``url`` — full server URL, e.g. ``http://localhost:8000/mcp``
81
+ * ``headers`` — extra HTTP headers (e.g. ``Authorization``)
82
+ * ``timeout`` — total request timeout in seconds (default 30)
83
+ """
84
+
85
+ url: str
86
+ headers: Mapping[str, str] = field(default_factory=dict)
87
+ timeout: float = 30.0
88
+
89
+ def __post_init__(self) -> None:
90
+ if not isinstance(self.url, str) or not self.url:
91
+ raise ValueError("HttpServerParameters.url must be a non-empty string")
92
+ if not (self.url.startswith("http://") or self.url.startswith("https://")):
93
+ raise ValueError(
94
+ f"HttpServerParameters.url must start with http:// or https:// (got {self.url!r})"
95
+ )
96
+ if not isinstance(self.headers, Mapping):
97
+ raise ValueError("HttpServerParameters.headers must be a mapping of str->str")
98
+ for k, v in self.headers.items():
99
+ if not isinstance(k, str) or not isinstance(v, str):
100
+ raise ValueError(
101
+ "HttpServerParameters.headers must map str->str "
102
+ f"(got {type(k).__name__}->{type(v).__name__})"
103
+ )
104
+ if not isinstance(self.timeout, (int, float)) or self.timeout <= 0:
105
+ raise ValueError(
106
+ f"HttpServerParameters.timeout must be a positive number (got {self.timeout!r})"
107
+ )
108
+
109
+ def __str__(self) -> str: # pragma: no cover - cosmetic
110
+ return f"HttpServerParameters({self.url})"
mcpsync/py.typed ADDED
File without changes
mcpsync/sync.py ADDED
@@ -0,0 +1,61 @@
1
+ """``@sync`` decorator — call any async function synchronously.
2
+
3
+ The decorator runs the wrapped coroutine in a private ``asyncio.Runner``
4
+ so the caller can use plain blocking call sites::
5
+
6
+ from mcpsync import sync
7
+
8
+ @sync
9
+ async def get_weather(city: str) -> str:
10
+ return await some_async_call(city)
11
+
12
+ result = get_weather("London") # blocking, returns str
13
+
14
+ If the wrapped coroutine raises, the same exception is re-raised on the
15
+ caller's thread — no ``RuntimeError`` wrapping, no swallowed traceback.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import functools
22
+ import inspect
23
+ from typing import Any, Callable, TypeVar
24
+
25
+ F = TypeVar("F", bound=Callable[..., Any])
26
+
27
+ __all__ = ["sync"]
28
+
29
+
30
+ def sync(fn: F) -> F:
31
+ """Decorate an async function so it can be called synchronously.
32
+
33
+ Each call to the wrapped function runs in its own ``asyncio.Runner``,
34
+ which means the function must NOT already be running on an event
35
+ loop. Trying to call it from inside an async context will raise
36
+ ``RuntimeError`` from ``asyncio.Runner.run``.
37
+ """
38
+ if not inspect.iscoroutinefunction(fn):
39
+ raise TypeError(
40
+ f"@sync must be applied to a coroutine function (got {fn!r}); "
41
+ "did you forget `async def`?"
42
+ )
43
+
44
+ @functools.wraps(fn)
45
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
46
+ coro = fn(*args, **kwargs)
47
+ if not inspect.iscoroutine(coro) or asyncio.iscoroutine(coro) is False:
48
+ # defensive: fn is marked async but did not return a coroutine
49
+ if hasattr(coro, "__await__"):
50
+ pass
51
+ else:
52
+ raise TypeError(
53
+ f"@sync-wrapped function {fn.__qualname__} did not return a coroutine"
54
+ )
55
+ with asyncio.Runner() as runner:
56
+ return runner.run(coro)
57
+
58
+ # Mark the wrapper as a regular function so users see a sync signature
59
+ # in help()/REPL output. functools.wraps already preserves __wrapped__.
60
+ wrapper.__mcpsync_sync_decorator__ = True # type: ignore[attr-defined]
61
+ return wrapper # type: ignore[return-value]
@@ -0,0 +1,240 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcpsync-cli
3
+ Version: 0.1.0
4
+ Summary: Synchronous MCP Python client — call MCP servers from sync Python without async/await
5
+ Author-email: Repo Factory <noreply@example.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/prasad-a-abhishek/mcpsync
8
+ Project-URL: Repository, https://github.com/prasad-a-abhishek/mcpsync
9
+ Project-URL: Issues, https://github.com/prasad-a-abhishek/mcpsync/issues
10
+ Keywords: mcp,model-context-protocol,synchronous,sync,client,stdio,http,streamable-http
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.11
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: mcp>=1.0
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=7.0; extra == "dev"
26
+ Dynamic: license-file
27
+
28
+ # mcpsync
29
+
30
+ [![PyPI](https://img.shields.io/badge/version-0.1.0-blue)](https://pypi.org/project/mcpsync)
31
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
32
+ [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/)
33
+ [![Tests](https://img.shields.io/badge/tests-129%20passing-brightgreen.svg)](tests/)
34
+ [![Min runtime deps](https://img.shields.io/badge/dependencies-mcp-blue)](pyproject.toml)
35
+
36
+ > **Synchronous Python API for MCP servers — stdio and HTTP transports, plus a CLI for ad-hoc inspection.**
37
+
38
+ `mcpsync` wraps the official `mcp` Python SDK's async client behind a
39
+ blocking `SyncMCPClient` so you can call MCP tools, list resources, and
40
+ read resources from synchronous code (CLI tools, Django views, Flask
41
+ handlers, scripts). It also ships a CLI for ad-hoc server inspection
42
+ from the shell.
43
+
44
+ ## Quick Start
45
+
46
+ Install from source:
47
+
48
+ ```bash
49
+ pip install git+https://github.com/prasad-a-abhishek/mcpsync.git
50
+ ```
51
+
52
+ Connect to a stdio MCP server and call a tool:
53
+
54
+ ```python
55
+ from mcpsync import SyncMCPClient, StdioServerParameters
56
+
57
+ params = StdioServerParameters(
58
+ command="python", args=("-m", "my_mcp_server"),
59
+ env={"DEBUG": "1"},
60
+ cwd="/srv/myapp",
61
+ )
62
+
63
+ with SyncMCPClient(params) as client:
64
+ tools = client.list_tools()
65
+ for tool in tools:
66
+ print(f"- {tool.name}: {tool.description}")
67
+
68
+ result = client.call_tool("add", {"a": 2, "b": 3})
69
+ for block in result.content:
70
+ print(block.text)
71
+ ```
72
+
73
+ Connect to an HTTP MCP server:
74
+
75
+ ```python
76
+ from mcpsync import SyncMCPClient, HttpServerParameters
77
+
78
+ params = HttpServerParameters(
79
+ url="https://mcp.example.com/api",
80
+ headers={"Authorization": "Bearer ..."},
81
+ timeout=10.0,
82
+ )
83
+
84
+ with SyncMCPClient(params) as client:
85
+ resources = client.list_resources()
86
+ contents = client.read_resource(resources[0].uri)
87
+ ```
88
+
89
+ Turn any async MCP helper into a sync function with the `@sync`
90
+ decorator:
91
+
92
+ ```python
93
+ from mcpsync import sync
94
+
95
+ @sync
96
+ async def load_schema(server_url: str) -> dict:
97
+ async with some_async_helper(server_url) as helper:
98
+ return await helper.fetch_schema()
99
+ ```
100
+
101
+ ## ⚡ Performance & Benchmarks
102
+
103
+ We publish `benchmarks/BENCHMARK.md` per Invariant 14 — including
104
+ the methodology caveat. The honest summary: mcpsync uses
105
+ `asyncio.Runner` per call, which actually benchmarks ~15% faster
106
+ than the SDK's `asyncio.run` pattern in this setup (interpreter
107
+ boot dominates; relative comparison is meaningful). Reproduce
108
+ locally:
109
+
110
+ ```bash
111
+ python benchmarks/run_benchmark.py
112
+ ```
113
+
114
+ The full results table, methodology, and the trade-off
115
+ transparency statement are in
116
+ [benchmarks/BENCHMARK.md](benchmarks/BENCHMARK.md).
117
+
118
+ ## Why `mcpsync`? (Problem & Trade-Off Statement)
119
+
120
+ The MCP Python SDK ships an **async-only** client. Synchronous callers
121
+ (Django views, CLI tools, scripts) hit the same wall: every
122
+ `asyncio.run()` from sync code re-creates an event loop, leaks
123
+ resources, and breaks under `asyncio.run()` recursion if the
124
+ surrounding runtime already runs a loop.
125
+
126
+ Two GitHub issues document the gap:
127
+
128
+ - [`modelcontextprotocol/python-sdk#1223`](https://github.com/modelcontextprotocol/python-sdk/issues/1223) — "Sync client API" (open, multiple reactions)
129
+ - `modelcontextprotocol/python-sdk` — the SDK explicitly documents
130
+ the `client.session.stdio` use pattern as **async only**.
131
+
132
+ **What mcpsync is:** a thin wrapper that gives you `SyncMCPClient` +
133
+ a `@sync` decorator, plus a CLI for ad-hoc inspection. It uses the
134
+ official `mcp` SDK under the hood and never re-implements the JSON-RPC
135
+ protocol or transport.
136
+
137
+ **What mcpsync is NOT:**
138
+ - Not a replacement for the `mcp` SDK — it depends on it.
139
+ - Not an MCP server implementation. It's a client.
140
+ - Not magic. Each `SyncMCPClient.list_tools()` call creates a
141
+ one-shot event loop on a worker thread. If you need 100k calls/sec,
142
+ use the SDK's async client directly.
143
+
144
+ **Trade-offs you accept by using mcpsync:**
145
+ - Each `SyncMCPClient` call creates a fresh `asyncio.Runner` on a
146
+ worker thread. The bench shows this is ~15% faster than the SDK
147
+ baseline, but for a true long-lived async loop you should use
148
+ the SDK's async client directly.
149
+ - The close path needs a wall-clock fuse to escape a known deadlock
150
+ in the `mcp` SDK's `stdio_client.__aexit__` shielded cancel scope.
151
+ Without the fuse the caller's process hangs. See
152
+ `src/mcpsync/client.py::_run_bounded` and the docstring on
153
+ `SyncMCPClient.close()`.
154
+ - HTTP transport requires the `mcp` SDK's optional `httpx2` dep,
155
+ which is re-exported by `mcp` for convenience.
156
+
157
+ ## Key Features & Complete API / CLI Reference
158
+
159
+ ### Library API
160
+
161
+ | Name | Returns | Notes |
162
+ |---|---|---|
163
+ | `StdioServerParameters(command, args, env, cwd)` | dataclass | spawn the server as a subprocess |
164
+ | `HttpServerParameters(url, headers, timeout)` | dataclass | connect to a streamable-HTTP MCP server |
165
+ | `SyncMCPClient(params)` | context manager | open the session; use as `with` block |
166
+ | `client.list_tools()` | `list[Tool]` | tool descriptors from `mcp.types.Tool` |
167
+ | `client.list_resources()` | `list[Resource]` | resource descriptors from `mcp.types.Resource` |
168
+ | `client.call_tool(name, arguments)` | `CallToolResult` | invoke a tool; `result.content` is a list of typed blocks |
169
+ | `client.read_resource(uri)` | `ReadResourceResult` | fetch a resource by URI |
170
+ | `@sync` decorator | wraps an async function into a sync one | uses a per-call event loop |
171
+ | `MCPError` | exception | re-exported from `mcp.shared.exceptions` |
172
+
173
+ All public types are fully type-hinted. A `py.typed` marker ships in
174
+ `src/mcpsync/` for PEP 561.
175
+
176
+ ### CLI
177
+
178
+ ```text
179
+ $ mcpsync --help
180
+ usage: mcpsync [-h] [--version] {stdio,http} ...
181
+
182
+ Synchronous client for MCP servers.
183
+
184
+ $ mcpsync stdio list-tools -- python -m my_server
185
+ [
186
+ {"name": "echo", "description": "Echo the input message back", "inputSchema": {...}},
187
+ ...
188
+ ]
189
+
190
+ $ mcpsync stdio call-tool add '{"a":2,"b":3}' -- python -m my_server
191
+ {"content":[{"type":"text","text":"5"}]}
192
+
193
+ $ mcpsync stdio list-resources -- python -m my_server
194
+ [{"uri": "file:///greeting.txt", "name": "greeting", "mimeType": "text/plain"}, ...]
195
+
196
+ $ mcpsync stdio call-resource 'file:///greeting.txt' -- python -m my_server
197
+ {"contents":[{"uri": "file:///greeting.txt", "text": "Hello!", "mimeType": "text/plain"}]}
198
+
199
+ $ mcpsync http list-tools --url https://mcp.example.com/api
200
+ $ mcpsync http call-tool add '{"a":2}' --url https://mcp.example.com/api
201
+ ```
202
+
203
+ The `stdio` subcommand takes a `--` separator before the server
204
+ command + args. The `http` subcommand takes `--url`, `--header` (repeatable
205
+ `KEY=VAL`), and `--timeout`.
206
+
207
+ ### Out of scope
208
+
209
+ - MCP server implementation
210
+ - Long-lived event-loop reuse across calls (each call creates a new
211
+ one; see trade-off above)
212
+ - Streaming / subscription primitives
213
+ - Server-side protocol: `mcpsync` is a client only
214
+ - Any protocol version below what the bundled `mcp` SDK supports
215
+
216
+ ### Limitations
217
+
218
+ - **One shot per call.** Each `SyncMCPClient` use opens the server
219
+ process, runs the request, and closes. If you need a long-lived
220
+ connection, keep the `with` block open and make many calls inside.
221
+ - **HTTP transport** requires the `httpx2` runtime dep (re-exported
222
+ by `mcp` for convenience).
223
+ - **Close path** is bounded by a wall-clock fuse to avoid a known
224
+ deadlock in the `mcp` SDK's stdio teardown.
225
+
226
+ ## Tests
227
+
228
+ ```bash
229
+ pytest tests/ -q
230
+ ```
231
+
232
+ 129 tests across 1 file (split into 16 test classes by behavior).
233
+ Coverage spans every spec acceptance criterion plus angular sweep
234
+ (empty/None inputs, unicode, large payloads, malformed JSON, CLI
235
+ end-to-end, concurrent `@sync` calls, parameter adversarial
236
+ inputs, and regression guards for the close-path deadlock).
237
+
238
+ ## License
239
+
240
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,15 @@
1
+ mcpsync/__init__.py,sha256=U0_YWjQK8FFBorjpRjdDxzInxJA3b35h46pyNZDhois,945
2
+ mcpsync/__init__.pyi,sha256=BXRhZiTHJzr_sXVThj0hPCM3_GeckbeWrsuqQWLuHcg,1759
3
+ mcpsync/__main__.py,sha256=MPt-rvCvjrxtQFn0tjdi-ywttLb3RjIVhTf73YKc44A,109
4
+ mcpsync/cli.py,sha256=w7nWIkX8cGIFZzzfGQncbA2awLR2dGNb0IrIj7ZuNC4,11599
5
+ mcpsync/client.py,sha256=DhXbNPeOhQJ1hwkIDMVjP_jTeRme7Sg3Vj2zfQiyzbM,17737
6
+ mcpsync/errors.py,sha256=qS6N4ljlaShWSA1EAr69Upj309KLBrA7y6cyajGPbnI,380
7
+ mcpsync/params.py,sha256=fI6t95hYvgkawY7JZeE8c_L1HzKqpWaeEz6GHiUA1oc,4573
8
+ mcpsync/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ mcpsync/sync.py,sha256=r3MnWliLqIhHNRluNTtKcgxIFtTnwMbro7IDkWW8YPg,2126
10
+ mcpsync_cli-0.1.0.dist-info/licenses/LICENSE,sha256=FEO3Vgpd6moNnacnqGNTvfFZQxSqp78685C0c8NgxeU,1069
11
+ mcpsync_cli-0.1.0.dist-info/METADATA,sha256=hd30NxKebMtCaA2Hkk2bhtVTEYUJAg1LF7zVAJSlUXk,8864
12
+ mcpsync_cli-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
13
+ mcpsync_cli-0.1.0.dist-info/entry_points.txt,sha256=Th1DGB67zk1pf5fQPyep87M1MXyIReIdQHayMNoeSvE,45
14
+ mcpsync_cli-0.1.0.dist-info/top_level.txt,sha256=etrdpSlpG6d_DMKUoR6Hhqx0ul9H9Qh7xqRcfAEIgTo,8
15
+ mcpsync_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mcpsync = mcpsync.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Repo Factory
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ mcpsync