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 +31 -0
- mcpsync/__init__.pyi +64 -0
- mcpsync/__main__.py +5 -0
- mcpsync/cli.py +330 -0
- mcpsync/client.py +437 -0
- mcpsync/errors.py +13 -0
- mcpsync/params.py +110 -0
- mcpsync/py.typed +0 -0
- mcpsync/sync.py +61 -0
- mcpsync_cli-0.1.0.dist-info/METADATA +240 -0
- mcpsync_cli-0.1.0.dist-info/RECORD +15 -0
- mcpsync_cli-0.1.0.dist-info/WHEEL +5 -0
- mcpsync_cli-0.1.0.dist-info/entry_points.txt +2 -0
- mcpsync_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- mcpsync_cli-0.1.0.dist-info/top_level.txt +1 -0
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
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
|
+
[](https://pypi.org/project/mcpsync)
|
|
31
|
+
[](LICENSE)
|
|
32
|
+
[](https://www.python.org/)
|
|
33
|
+
[](tests/)
|
|
34
|
+
[](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,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
|