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.
@@ -0,0 +1,5 @@
1
+ """mcp2postman: turn an MCP server into a tested Postman collection."""
2
+
3
+ __version__ = "1.0.0"
4
+
5
+ __all__ = ["__version__"]
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
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())