mcp2postman 1.0.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.
- mcp2postman/__init__.py +5 -0
- mcp2postman/__main__.py +5 -0
- mcp2postman/cli.py +358 -0
- mcp2postman/client.py +383 -0
- mcp2postman/config.py +218 -0
- mcp2postman/discovery.py +125 -0
- mcp2postman/mcp_schemas.py +216 -0
- mcp2postman/postman.py +689 -0
- mcp2postman/schema_values.py +436 -0
- mcp2postman/scripts.py +278 -0
- mcp2postman-1.0.0.dist-info/METADATA +406 -0
- mcp2postman-1.0.0.dist-info/RECORD +16 -0
- mcp2postman-1.0.0.dist-info/WHEEL +5 -0
- mcp2postman-1.0.0.dist-info/entry_points.txt +2 -0
- mcp2postman-1.0.0.dist-info/licenses/LICENSE.md +202 -0
- mcp2postman-1.0.0.dist-info/top_level.txt +1 -0
mcp2postman/__init__.py
ADDED
mcp2postman/__main__.py
ADDED
mcp2postman/cli.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""Command line interface: ``mcp2postman``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import shlex
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
from .client import SUPPORTED_PROTOCOL_VERSIONS, HttpTransport, MCPError, StdioTransport, Transport
|
|
15
|
+
from .config import load_server
|
|
16
|
+
from .discovery import ServerSnapshot, discover
|
|
17
|
+
from .postman import GeneratorOptions, build_collection_and_environment
|
|
18
|
+
from .schema_values import required_and_optional
|
|
19
|
+
|
|
20
|
+
DEFAULT_BASE_URL = "http://localhost:3000/mcp"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _parse_header(raw: str) -> Tuple[str, str]:
|
|
24
|
+
if ":" not in raw:
|
|
25
|
+
raise argparse.ArgumentTypeError(f"header must look like 'Name: value', got {raw!r}")
|
|
26
|
+
name, value = raw.split(":", 1)
|
|
27
|
+
name = name.strip()
|
|
28
|
+
if not name:
|
|
29
|
+
raise argparse.ArgumentTypeError(f"empty header name in {raw!r}")
|
|
30
|
+
return name, value.strip()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _parse_versions(raw: str) -> List[str]:
|
|
34
|
+
if raw.strip().lower() == "none":
|
|
35
|
+
return []
|
|
36
|
+
versions = [v.strip() for v in raw.split(",") if v.strip()]
|
|
37
|
+
for version in versions:
|
|
38
|
+
if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", version):
|
|
39
|
+
raise argparse.ArgumentTypeError(f"protocol version must look like YYYY-MM-DD, got {version!r}")
|
|
40
|
+
return versions
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _parse_env(raw: str) -> Tuple[str, str]:
|
|
44
|
+
if "=" not in raw:
|
|
45
|
+
raise argparse.ArgumentTypeError(f"env must look like KEY=VALUE, got {raw!r}")
|
|
46
|
+
key, value = raw.split("=", 1)
|
|
47
|
+
return key, value
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _add_source_args(parser: argparse.ArgumentParser, allow_snapshot: bool) -> None:
|
|
51
|
+
src = parser.add_argument_group("MCP server (choose one)")
|
|
52
|
+
group = src.add_mutually_exclusive_group(required=True)
|
|
53
|
+
group.add_argument("--url", help="Streamable HTTP endpoint of the MCP server, e.g. http://localhost:3000/mcp")
|
|
54
|
+
group.add_argument(
|
|
55
|
+
"--stdio",
|
|
56
|
+
metavar="COMMAND",
|
|
57
|
+
help="command that starts a stdio MCP server, e.g. \"npx -y @modelcontextprotocol/server-everything\"",
|
|
58
|
+
)
|
|
59
|
+
group.add_argument(
|
|
60
|
+
"--config",
|
|
61
|
+
metavar="FILE",
|
|
62
|
+
help="mcp-config.json (Claude/Cursor 'mcpServers' or VS Code 'servers' format); "
|
|
63
|
+
"url/command, args, env and headers (auth) are taken from it",
|
|
64
|
+
)
|
|
65
|
+
if allow_snapshot:
|
|
66
|
+
group.add_argument("--from-snapshot", metavar="FILE", help="reuse a snapshot saved with --snapshot-out")
|
|
67
|
+
src.add_argument(
|
|
68
|
+
"--server",
|
|
69
|
+
metavar="NAME",
|
|
70
|
+
help="server to use from --config (required when the file defines several)",
|
|
71
|
+
)
|
|
72
|
+
src.add_argument(
|
|
73
|
+
"-H",
|
|
74
|
+
"--header",
|
|
75
|
+
dest="headers",
|
|
76
|
+
action="append",
|
|
77
|
+
type=_parse_header,
|
|
78
|
+
default=[],
|
|
79
|
+
metavar="'NAME: VALUE'",
|
|
80
|
+
help="extra HTTP header sent to the server (repeatable), e.g. -H 'Authorization: Bearer xxx'; "
|
|
81
|
+
"overrides a header of the same name from --config",
|
|
82
|
+
)
|
|
83
|
+
src.add_argument(
|
|
84
|
+
"--env",
|
|
85
|
+
dest="env",
|
|
86
|
+
action="append",
|
|
87
|
+
type=_parse_env,
|
|
88
|
+
default=[],
|
|
89
|
+
metavar="KEY=VALUE",
|
|
90
|
+
help="environment variable for the stdio server (repeatable); overrides --config env",
|
|
91
|
+
)
|
|
92
|
+
src.add_argument(
|
|
93
|
+
"--protocol-version",
|
|
94
|
+
default=None,
|
|
95
|
+
help="protocol version proposed during initialize. Default: the newest supported "
|
|
96
|
+
f"({', '.join(SUPPORTED_PROTOCOL_VERSIONS)}), falling back to older ones if the server rejects it",
|
|
97
|
+
)
|
|
98
|
+
src.add_argument("--timeout", type=float, default=30.0, help="seconds to wait for each response (default: 30)")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
102
|
+
parser = argparse.ArgumentParser(
|
|
103
|
+
prog="mcp2postman",
|
|
104
|
+
description="Turn an MCP server into a Postman collection with contract and negative tests.",
|
|
105
|
+
)
|
|
106
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
107
|
+
sub = parser.add_subparsers(dest="command", metavar="COMMAND")
|
|
108
|
+
sub.required = True
|
|
109
|
+
|
|
110
|
+
gen = sub.add_parser(
|
|
111
|
+
"generate",
|
|
112
|
+
help="discover an MCP server and write a Postman collection",
|
|
113
|
+
description="Discover an MCP server and write a Postman v2.1 collection.",
|
|
114
|
+
)
|
|
115
|
+
_add_source_args(gen, allow_snapshot=True)
|
|
116
|
+
out = gen.add_argument_group("output")
|
|
117
|
+
out.add_argument("-o", "--output", help="collection file ('-' for stdout). Default: <server>.postman_collection.json")
|
|
118
|
+
out.add_argument(
|
|
119
|
+
"--environment-out",
|
|
120
|
+
metavar="FILE",
|
|
121
|
+
help="Postman environment file (always written) with baseUrl, header variables and, with "
|
|
122
|
+
"--body-variables, the argument values. Default: next to the collection, "
|
|
123
|
+
"<name>.postman_environment.json",
|
|
124
|
+
)
|
|
125
|
+
out.add_argument("--snapshot-out", metavar="FILE", help="also save the discovered server snapshot (JSON)")
|
|
126
|
+
out.add_argument("--name", help="collection name (default: '<server title> (MCP)')")
|
|
127
|
+
out.add_argument(
|
|
128
|
+
"--base-url",
|
|
129
|
+
help="value of {{baseUrl}} in the collection (default: --url, or "
|
|
130
|
+
f"{DEFAULT_BASE_URL} for stdio servers)",
|
|
131
|
+
)
|
|
132
|
+
out.add_argument(
|
|
133
|
+
"--embed-header-values",
|
|
134
|
+
action="store_true",
|
|
135
|
+
help="store -H values in the collection (by default only empty variables are created; "
|
|
136
|
+
"beware of committing secrets)",
|
|
137
|
+
)
|
|
138
|
+
out.add_argument(
|
|
139
|
+
"--body-variables",
|
|
140
|
+
action="store_true",
|
|
141
|
+
help="put tool/prompt argument values in environment variables ({{tool_arg}}) instead of "
|
|
142
|
+
"hard-coding them in the request bodies",
|
|
143
|
+
)
|
|
144
|
+
sel = gen.add_argument_group("content")
|
|
145
|
+
sel.add_argument("--include-tool", action="append", default=[], metavar="GLOB", help="only these tools (repeatable)")
|
|
146
|
+
sel.add_argument("--exclude-tool", action="append", default=[], metavar="GLOB", help="skip these tools (repeatable)")
|
|
147
|
+
sel.add_argument(
|
|
148
|
+
"--values",
|
|
149
|
+
metavar="FILE",
|
|
150
|
+
help='JSON file with argument values per tool: {"tool": {"arg": value}}',
|
|
151
|
+
)
|
|
152
|
+
sel.add_argument("--no-negative-tests", action="store_true", help="skip 'missing required' and 'wrong type' requests")
|
|
153
|
+
sel.add_argument("--no-wrong-type-tests", action="store_true", help="skip 'wrong type' requests")
|
|
154
|
+
sel.add_argument("--no-optional-variants", action="store_true", help="skip one request per optional attribute")
|
|
155
|
+
sel.add_argument("--no-protocol-tests", action="store_true", help="skip generic JSON-RPC conformance requests")
|
|
156
|
+
sel.add_argument(
|
|
157
|
+
"--protocol-versions",
|
|
158
|
+
type=_parse_versions,
|
|
159
|
+
default=list(SUPPORTED_PROTOCOL_VERSIONS),
|
|
160
|
+
metavar="V1,V2",
|
|
161
|
+
help="protocol versions checked in the '91 Protocol versions' folder, comma separated "
|
|
162
|
+
f"(default: {','.join(SUPPORTED_PROTOCOL_VERSIONS)}; 'none' to skip the folder)",
|
|
163
|
+
)
|
|
164
|
+
sel.add_argument("--no-resources", action="store_true", help="skip the resources folder")
|
|
165
|
+
sel.add_argument("--no-prompts", action="store_true", help="skip the prompts folder")
|
|
166
|
+
sel.add_argument("--no-teardown", action="store_true", help="skip the session DELETE request")
|
|
167
|
+
gen.add_argument("-q", "--quiet", action="store_true", help="only print errors")
|
|
168
|
+
gen.set_defaults(func=cmd_generate)
|
|
169
|
+
|
|
170
|
+
insp = sub.add_parser(
|
|
171
|
+
"inspect",
|
|
172
|
+
help="print what an MCP server exposes",
|
|
173
|
+
description="Connect to an MCP server and print its tools, resources and prompts.",
|
|
174
|
+
)
|
|
175
|
+
_add_source_args(insp, allow_snapshot=False)
|
|
176
|
+
insp.add_argument("--json", action="store_true", help="print the full snapshot as JSON")
|
|
177
|
+
insp.set_defaults(func=cmd_inspect)
|
|
178
|
+
return parser
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _merge(base: Dict[str, str], override: Sequence[Tuple[str, str]], case_insensitive: bool) -> List[Tuple[str, str]]:
|
|
182
|
+
merged = dict(base)
|
|
183
|
+
for key, value in override:
|
|
184
|
+
if case_insensitive:
|
|
185
|
+
for existing in [k for k in merged if k.lower() == key.lower()]:
|
|
186
|
+
del merged[existing]
|
|
187
|
+
merged[key] = value
|
|
188
|
+
return list(merged.items())
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _apply_config(args: argparse.Namespace) -> None:
|
|
192
|
+
"""Resolve --config/--server into --url/--stdio, headers, env and cwd."""
|
|
193
|
+
args.cwd = None
|
|
194
|
+
if args.server and not args.config:
|
|
195
|
+
raise ValueError("--server requires --config")
|
|
196
|
+
if not args.config:
|
|
197
|
+
return
|
|
198
|
+
server = load_server(args.config, args.server)
|
|
199
|
+
args.config_server = server.name
|
|
200
|
+
if server.is_http:
|
|
201
|
+
args.url = server.url
|
|
202
|
+
args.headers = _merge(server.headers, args.headers, case_insensitive=True)
|
|
203
|
+
else:
|
|
204
|
+
args.stdio = server.argv
|
|
205
|
+
args.env = _merge(server.env, args.env, case_insensitive=False)
|
|
206
|
+
args.cwd = server.cwd
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _transport(args: argparse.Namespace) -> Transport:
|
|
210
|
+
headers = dict(args.headers)
|
|
211
|
+
if args.url:
|
|
212
|
+
return HttpTransport(args.url, headers=headers, timeout=args.timeout)
|
|
213
|
+
if headers:
|
|
214
|
+
print("warning: -H headers are not used to talk to a stdio server (only added to the collection)", file=sys.stderr)
|
|
215
|
+
return StdioTransport(args.stdio, env=dict(args.env), cwd=args.cwd, timeout=args.timeout)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _snapshot(args: argparse.Namespace, log) -> ServerSnapshot:
|
|
219
|
+
if getattr(args, "from_snapshot", None):
|
|
220
|
+
return ServerSnapshot.load(args.from_snapshot)
|
|
221
|
+
_apply_config(args)
|
|
222
|
+
stdio = shlex.join(args.stdio) if isinstance(args.stdio, list) else args.stdio
|
|
223
|
+
source: Dict[str, Any] = {"url": args.url} if args.url else {"stdio": stdio}
|
|
224
|
+
if args.config:
|
|
225
|
+
# Only the location: env values and headers may hold secrets.
|
|
226
|
+
source["config"] = {"file": args.config, "server": args.config_server}
|
|
227
|
+
with _transport(args) as transport:
|
|
228
|
+
return discover(transport, args.protocol_version, source, log=log)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _slug(text: str) -> str:
|
|
232
|
+
return re.sub(r"[^a-zA-Z0-9]+", "-", text).strip("-").lower() or "mcp-server"
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _load_values(path: Optional[str]) -> Dict[str, Dict[str, Any]]:
|
|
236
|
+
if not path:
|
|
237
|
+
return {}
|
|
238
|
+
data = json.loads(Path(path).read_text("utf-8"))
|
|
239
|
+
if not isinstance(data, dict) or not all(isinstance(v, dict) for v in data.values()):
|
|
240
|
+
raise ValueError('--values must contain an object like {"tool": {"arg": value}}')
|
|
241
|
+
return data
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _write_json(path: str, data: Any) -> None:
|
|
245
|
+
text = json.dumps(data, indent=2, ensure_ascii=False) + "\n"
|
|
246
|
+
if path == "-":
|
|
247
|
+
sys.stdout.write(text)
|
|
248
|
+
else:
|
|
249
|
+
Path(path).write_text(text, "utf-8")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def cmd_generate(args: argparse.Namespace) -> int:
|
|
253
|
+
log = (lambda _m: None) if args.quiet else (lambda m: print(m, file=sys.stderr))
|
|
254
|
+
snapshot = _snapshot(args, log)
|
|
255
|
+
base_url = args.base_url or args.url or (snapshot.source or {}).get("url") or DEFAULT_BASE_URL
|
|
256
|
+
if not args.base_url and not args.url and not (snapshot.source or {}).get("url"):
|
|
257
|
+
log(
|
|
258
|
+
f"note: stdio server -> collection targets {base_url}; expose the server over "
|
|
259
|
+
"Streamable HTTP and pass --base-url (or edit {{baseUrl}})."
|
|
260
|
+
)
|
|
261
|
+
options = GeneratorOptions(
|
|
262
|
+
base_url=base_url,
|
|
263
|
+
collection_name=args.name,
|
|
264
|
+
headers=dict(args.headers),
|
|
265
|
+
embed_header_values=args.embed_header_values,
|
|
266
|
+
include_tools=args.include_tool,
|
|
267
|
+
exclude_tools=args.exclude_tool,
|
|
268
|
+
tool_values=_load_values(args.values),
|
|
269
|
+
negative_tests=not args.no_negative_tests,
|
|
270
|
+
wrong_type_tests=not args.no_wrong_type_tests,
|
|
271
|
+
optional_variants=not args.no_optional_variants,
|
|
272
|
+
protocol_tests=not args.no_protocol_tests,
|
|
273
|
+
protocol_versions=args.protocol_versions,
|
|
274
|
+
include_resources=not args.no_resources,
|
|
275
|
+
include_prompts=not args.no_prompts,
|
|
276
|
+
teardown=not args.no_teardown,
|
|
277
|
+
body_variables=args.body_variables,
|
|
278
|
+
)
|
|
279
|
+
collection, environment = build_collection_and_environment(
|
|
280
|
+
snapshot, options, f"{snapshot.server_name} (MCP)"
|
|
281
|
+
)
|
|
282
|
+
output = args.output or f"{_slug(snapshot.server_name)}.postman_collection.json"
|
|
283
|
+
env_output = args.environment_out or environment_path(output, snapshot.server_name)
|
|
284
|
+
_write_json(output, collection)
|
|
285
|
+
_write_json(env_output, environment)
|
|
286
|
+
if args.snapshot_out:
|
|
287
|
+
snapshot.save(args.snapshot_out)
|
|
288
|
+
requests = _count_requests(collection["item"])
|
|
289
|
+
if output != "-":
|
|
290
|
+
log(f"Wrote {output} ({requests} requests)")
|
|
291
|
+
if env_output != "-":
|
|
292
|
+
log(f"Wrote {env_output} ({len(environment['values'])} variables)")
|
|
293
|
+
for warning in snapshot.warnings:
|
|
294
|
+
log(f"warning: {warning}")
|
|
295
|
+
return 0
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def environment_path(collection_path: str, server_name: str) -> str:
|
|
299
|
+
"""Default environment file, next to the collection."""
|
|
300
|
+
if collection_path == "-":
|
|
301
|
+
return f"{_slug(server_name)}.postman_environment.json"
|
|
302
|
+
path = Path(collection_path)
|
|
303
|
+
for suffix in (".postman_collection.json", ".json"):
|
|
304
|
+
if path.name.endswith(suffix):
|
|
305
|
+
return str(path.with_name(path.name[: -len(suffix)] + ".postman_environment.json"))
|
|
306
|
+
return str(path.with_name(path.name + ".postman_environment.json"))
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _count_requests(items: List[Dict[str, Any]]) -> int:
|
|
310
|
+
return sum(_count_requests(i["item"]) if "item" in i else 1 for i in items)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def cmd_inspect(args: argparse.Namespace) -> int:
|
|
314
|
+
snapshot = _snapshot(args, lambda _m: None)
|
|
315
|
+
if args.json:
|
|
316
|
+
_write_json("-", snapshot.to_dict())
|
|
317
|
+
return 0
|
|
318
|
+
info = snapshot.server_info
|
|
319
|
+
print(f"{snapshot.server_name} {info.get('version', '')}".rstrip())
|
|
320
|
+
print(f" protocol: {snapshot.protocol_version}")
|
|
321
|
+
print(f" capabilities: {', '.join(sorted(snapshot.capabilities)) or 'none'}")
|
|
322
|
+
print(f"\nTools ({len(snapshot.tools)}):")
|
|
323
|
+
for tool in snapshot.tools:
|
|
324
|
+
required, optional, _ = required_and_optional(tool.get("inputSchema") or {})
|
|
325
|
+
parts = [f"{r}*" for r in required] + optional
|
|
326
|
+
print(f" - {tool.get('name')}({', '.join(parts)})")
|
|
327
|
+
if snapshot.resources or snapshot.resource_templates:
|
|
328
|
+
print(f"\nResources ({len(snapshot.resources)} + {len(snapshot.resource_templates)} templates):")
|
|
329
|
+
for res in snapshot.resources:
|
|
330
|
+
print(f" - {res.get('uri')}")
|
|
331
|
+
for tpl in snapshot.resource_templates:
|
|
332
|
+
print(f" - {tpl.get('uriTemplate')} (template)")
|
|
333
|
+
if snapshot.prompts:
|
|
334
|
+
print(f"\nPrompts ({len(snapshot.prompts)}):")
|
|
335
|
+
for prompt in snapshot.prompts:
|
|
336
|
+
args_ = [f"{a.get('name')}{'*' if a.get('required') else ''}" for a in prompt.get("arguments") or []]
|
|
337
|
+
print(f" - {prompt.get('name')}({', '.join(args_)})")
|
|
338
|
+
print("\n(* = required)")
|
|
339
|
+
for warning in snapshot.warnings:
|
|
340
|
+
print(f"warning: {warning}", file=sys.stderr)
|
|
341
|
+
return 0
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
345
|
+
parser = build_parser()
|
|
346
|
+
args = parser.parse_args(argv)
|
|
347
|
+
try:
|
|
348
|
+
return int(args.func(args) or 0)
|
|
349
|
+
except MCPError as err:
|
|
350
|
+
print(f"error: {err}", file=sys.stderr)
|
|
351
|
+
return 2
|
|
352
|
+
except (OSError, ValueError) as err:
|
|
353
|
+
print(f"error: {err}", file=sys.stderr)
|
|
354
|
+
return 1
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
if __name__ == "__main__": # pragma: no cover
|
|
358
|
+
sys.exit(main())
|