mcpsmith 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.
mcpsmith/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """mcpsmith: turn an OpenAPI file or a recorded website session into a curated, tested MCP server."""
2
+
3
+ __version__ = "0.1.0"
mcpsmith/capture.py ADDED
@@ -0,0 +1,49 @@
1
+ """Record a browsing session to HAR with a headed Chromium (Playwright, optional extra `capture`)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from pathlib import Path
8
+
9
+ INSTALL_HINT = 'pip install "mcpsmith[capture]" && playwright install chromium'
10
+
11
+
12
+ def record(url: str, out: Path, hosts: list[str] | None = None) -> Path:
13
+ """Open `url`, let the user browse, and write the session to `out` when the tab or browser is closed."""
14
+ try:
15
+ from playwright.sync_api import Error, sync_playwright
16
+ except ImportError as e:
17
+ raise RuntimeError(f"Recording needs Playwright: {INSTALL_HINT}") from e
18
+
19
+ out = Path(out)
20
+ out.parent.mkdir(parents=True, exist_ok=True)
21
+ options: dict = {"record_har_path": str(out), "record_har_content": "embed", "record_har_mode": "full"}
22
+ if hosts:
23
+ names = "|".join(re.escape(h.lstrip(".")) for h in hosts)
24
+ options["record_har_url_filter"] = re.compile(rf"^https?://([^/]*\.)?({names})(:\d+)?(/|$)")
25
+
26
+ # Chromium ignores proxy env vars; pass the developer's proxy on so recording works behind one.
27
+ proxy = os.environ.get("HTTPS_PROXY") or os.environ.get("https_proxy") or os.environ.get("HTTP_PROXY")
28
+ with sync_playwright() as p:
29
+ browser = p.chromium.launch(headless=False, proxy={"server": proxy} if proxy else None)
30
+ context = browser.new_context(**options)
31
+ page = context.new_page()
32
+ page.goto(url)
33
+ print("Recording. Use the site features you want as tools, then close the browser tab to save the HAR.")
34
+ while browser.is_connected() and context.pages:
35
+ try:
36
+ context.pages[0].wait_for_timeout(500)
37
+ except Error:
38
+ pass # the page closed while we waited; the loop condition decides
39
+ try:
40
+ context.close()
41
+ browser.close()
42
+ except Error:
43
+ pass # browser already gone
44
+ if not out.exists():
45
+ raise RuntimeError(f"No HAR written to {out}: close the tab rather than the whole browser window.")
46
+ print(
47
+ f"Warning: {out} holds cookies and personal data from this session. Keep it out of git and delete it after `inspect`."
48
+ )
49
+ return out
mcpsmith/check.py ADDED
@@ -0,0 +1,87 @@
1
+ """Drift check: call every tool of a generated project live with its example arguments and report what came back."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import json
7
+ import logging
8
+ import sys
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from types import ModuleType
12
+ from typing import Any
13
+
14
+ import anyio
15
+ from mcp import Client
16
+
17
+ from .model import WRITE_METHODS, Plan, endpoint_method, load_plan
18
+
19
+
20
+ @dataclass
21
+ class CheckResult:
22
+ tool: str
23
+ ok: bool
24
+ error: str | None
25
+ chars: int
26
+ empty_fields: list[str] = field(default_factory=list)
27
+ skipped: bool = False
28
+
29
+
30
+ def run(project: Path, writes: bool = False) -> list[CheckResult]:
31
+ """Load `<project>/mcpsmith.yaml`, import `<package>.server` from `<project>/src` and call each tool once.
32
+
33
+ Tools with read_only false (or a DELETE/PUT/PATCH endpoint written as 'METHOD /path') are skipped unless `writes`:
34
+ they would change data on the live API.
35
+ """
36
+ project = Path(project)
37
+ for name in ("httpx", "mcp"): # the report already says what failed; per-request log lines are noise
38
+ logging.getLogger(name).setLevel(logging.WARNING)
39
+ plan = load_plan(project / "mcpsmith.yaml")
40
+ server = _import_server(project / "src", plan.package)
41
+ return anyio.run(_check, plan, server, writes)
42
+
43
+
44
+ def _import_server(src: Path, package: str) -> ModuleType:
45
+ """Import a fresh copy of the project's server module, leaving sys.path and sys.modules as they were."""
46
+
47
+ def drop() -> None:
48
+ for name in [n for n in sys.modules if n == package or n.startswith(package + ".")]:
49
+ del sys.modules[name]
50
+
51
+ drop()
52
+ sys.path.insert(0, str(src))
53
+ try:
54
+ return importlib.import_module(f"{package}.server")
55
+ finally:
56
+ sys.path.remove(str(src))
57
+ drop()
58
+
59
+
60
+ async def _check(plan: Plan, server: ModuleType, writes: bool) -> list[CheckResult]:
61
+ results = []
62
+ async with Client(server.mcp) as client:
63
+ for t in plan.tools:
64
+ if (not t.read_only or endpoint_method(t.endpoint) in WRITE_METHODS) and not writes:
65
+ results.append(CheckResult(t.name, True, None, 0, skipped=True))
66
+ continue
67
+ try:
68
+ result = await client.call_tool(t.name, t.example)
69
+ except Exception as e: # one broken tool must not hide the report for the others
70
+ results.append(CheckResult(t.name, False, f"{type(e).__name__}: {e}", 0))
71
+ continue
72
+ text = "".join(getattr(c, "text", "") for c in result.content)
73
+ if result.is_error:
74
+ results.append(CheckResult(t.name, False, text, len(text)))
75
+ continue
76
+ try:
77
+ data = json.loads(text)
78
+ except ValueError:
79
+ data = None
80
+ results.append(CheckResult(t.name, True, None, len(text), _empty_fields(data, list(t.output.fields))))
81
+ return results
82
+
83
+
84
+ def _empty_fields(data: Any, fields: list[str]) -> list[str]:
85
+ """Output fields that are missing or empty in every returned item (output is already stripped of empties)."""
86
+ items = data["items"] if isinstance(data, dict) and isinstance(data.get("items"), list) else [data]
87
+ return [f for f in fields if all(not isinstance(i, dict) or i.get(f) in (None, "", [], {}) for i in items)]
mcpsmith/cli.py ADDED
@@ -0,0 +1,245 @@
1
+ """The `mcpsmith` command line: capture, inspect, draft, design, validate, generate, check, guide, mcp."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import dataclasses
7
+ import json
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ import httpx
12
+ import yaml
13
+
14
+ from .model import Plan, Source, load_plan, load_source, save_plan, save_source
15
+
16
+ DEFAULT_WORKDIR = ".mcpsmith"
17
+ EXPECTED = (OSError, ValueError, RuntimeError, ImportError, httpx.HTTPError, yaml.YAMLError)
18
+
19
+
20
+ # ---------- steps shared with the MCP server ----------
21
+
22
+
23
+ def inspect_source(source: str, workdir: Path, hosts: list[str] | None, budget: int) -> tuple[Source, Path, Path, str]:
24
+ """Load an OpenAPI file or HAR; write source.json and digest.md into workdir."""
25
+ from .design import digest
26
+ from .importers import load
27
+
28
+ src = load(source, hosts=hosts or None)
29
+ workdir.mkdir(parents=True, exist_ok=True)
30
+ source_path, digest_path = workdir / "source.json", workdir / "digest.md"
31
+ save_source(src, source_path)
32
+ text = digest(src, budget=budget)
33
+ digest_path.write_text(text, encoding="utf-8", newline="\n")
34
+ return src, source_path, digest_path, text
35
+
36
+
37
+ def load_workdir_source(workdir: Path) -> Source | None:
38
+ path = workdir / "source.json"
39
+ return load_source(path) if path.exists() else None
40
+
41
+
42
+ def split_issues(issues) -> tuple[list[str], list[str]]:
43
+ """Format design Issues as 'LEVEL where: message' lines: (errors, warnings)."""
44
+ errors = [f"ERROR {i.where}: {i.message}" for i in issues if i.level == "error"]
45
+ warnings = [f"WARNING {i.where}: {i.message}" for i in issues if i.level != "error"]
46
+ return errors, warnings
47
+
48
+
49
+ def draft_plan(workdir: Path, name: str | None, force: bool) -> Path:
50
+ from .design import draft
51
+
52
+ plan_path = workdir / "plan.yaml"
53
+ if plan_path.exists() and not force:
54
+ raise ValueError(f"{plan_path} already exists (use --force to overwrite it)")
55
+ source = load_source(workdir / "source.json")
56
+ save_plan(draft(source, name), plan_path)
57
+ return plan_path
58
+
59
+
60
+ # ---------- commands ----------
61
+
62
+
63
+ def _flags(args) -> str:
64
+ return "" if args.workdir == DEFAULT_WORKDIR else f" -w {args.workdir}"
65
+
66
+
67
+ def cmd_capture(args) -> int:
68
+ from .capture import record
69
+
70
+ out = record(args.url, Path(args.output), args.host or None)
71
+ print(f"saved {out}\nnext: mcpsmith inspect {out}")
72
+ return 0
73
+
74
+
75
+ def cmd_inspect(args) -> int:
76
+ src, source_path, digest_path, _ = inspect_source(args.source, Path(args.workdir), args.host, args.budget)
77
+ auth = ", ".join(f"{a.name} ({a.type})" for a in src.auth) or "none"
78
+ print(f"{src.title}: {len(src.endpoints)} endpoints, auth: {auth}")
79
+ print(f"wrote {source_path}\nwrote {digest_path}")
80
+ print(f"next: mcpsmith draft{_flags(args)} (or `mcpsmith design{_flags(args)}` to let an LLM write the plan)")
81
+ return 0
82
+
83
+
84
+ def cmd_draft(args) -> int:
85
+ plan_path = draft_plan(Path(args.workdir), args.name, args.force)
86
+ plan = load_plan(plan_path)
87
+ print(f"wrote {plan_path} ({len(plan.tools)} tools)")
88
+ print(f"next: edit it (see `mcpsmith guide`), then `mcpsmith validate{_flags(args)}`")
89
+ return 0
90
+
91
+
92
+ def cmd_design(args) -> int:
93
+ from .design.llm import design
94
+
95
+ workdir = Path(args.workdir)
96
+ plan_path = workdir / "plan.yaml"
97
+ start = load_plan(plan_path) if plan_path.exists() else None
98
+ plan = design(load_source(workdir / "source.json"), args.goal, args.provider, args.model, start)
99
+ save_plan(plan, plan_path)
100
+ print(f"wrote {plan_path}: " + ", ".join(t.name for t in plan.tools))
101
+ print(f"next: mcpsmith validate{_flags(args)}")
102
+ return 0
103
+
104
+
105
+ def _validated(args) -> tuple[Plan, Source | None, list[str]]:
106
+ """Load the plan, validate it, print warnings; return (plan, source, errors)."""
107
+ from .design import validate
108
+
109
+ workdir = Path(args.workdir)
110
+ plan = load_plan(args.plan or workdir / "plan.yaml")
111
+ source = load_workdir_source(workdir)
112
+ errors, warnings = split_issues(validate(plan, source))
113
+ for line in warnings:
114
+ print(line)
115
+ return plan, source, errors
116
+
117
+
118
+ def cmd_validate(args) -> int:
119
+ _, _, errors = _validated(args)
120
+ for line in errors:
121
+ print(line)
122
+ if not errors:
123
+ print("ok")
124
+ return 1 if errors else 0
125
+
126
+
127
+ def cmd_generate(args) -> int:
128
+ from .codegen import generate
129
+
130
+ plan, source, errors = _validated(args)
131
+ if errors:
132
+ for line in errors:
133
+ print(line)
134
+ print("not generated: fix the errors above", file=sys.stderr)
135
+ return 1
136
+ out = Path(args.output or plan.name)
137
+ files = generate(plan, out, source, force=args.force)
138
+ print(f"generated {len(files)} files in {out}")
139
+ print(f"next: cd {out} && uv run pytest\n mcpsmith check {out}")
140
+ return 0
141
+
142
+
143
+ def cmd_check(args) -> int:
144
+ from .check import run
145
+
146
+ results = run(Path(args.project), writes=args.writes)
147
+ if args.json:
148
+ print(json.dumps([dataclasses.asdict(r) for r in results], ensure_ascii=False, indent=1))
149
+ return 0 if all(r.ok for r in results) else 1
150
+ for r in results:
151
+ if r.skipped:
152
+ print(f"SKIP {r.tool} changes data on the live API; add --writes to call it")
153
+ elif r.ok:
154
+ empty = f" empty: {', '.join(r.empty_fields)}" if r.empty_fields else ""
155
+ print(f"OK {r.tool} {r.chars} chars{empty}")
156
+ else:
157
+ print(f"FAIL {r.tool} {r.error}")
158
+ return 0 if all(r.ok for r in results) else 1
159
+
160
+
161
+ def cmd_guide(args) -> int:
162
+ from .design import guide
163
+
164
+ print(guide())
165
+ return 0
166
+
167
+
168
+ def cmd_mcp(args) -> int:
169
+ from .server import main as serve
170
+
171
+ serve()
172
+ return 0
173
+
174
+
175
+ # ---------- parser ----------
176
+
177
+
178
+ def build_parser() -> argparse.ArgumentParser:
179
+ p = argparse.ArgumentParser(
180
+ prog="mcpsmith", description="Turn an OpenAPI file or a recorded website session into a curated MCP server."
181
+ )
182
+ sub = p.add_subparsers(dest="command", required=True, metavar="command")
183
+
184
+ def command(name, fn, help, workdir=True):
185
+ sp = sub.add_parser(name, help=help, description=help)
186
+ sp.set_defaults(fn=fn)
187
+ if workdir:
188
+ sp.add_argument(
189
+ "-w", "--workdir", default=DEFAULT_WORKDIR, help=f"working directory (default {DEFAULT_WORKDIR})"
190
+ )
191
+ return sp
192
+
193
+ sp = command("capture", cmd_capture, "record a browsing session of a website to a HAR file", workdir=False)
194
+ sp.add_argument("url")
195
+ sp.add_argument("-o", "--output", default="site.har")
196
+ sp.add_argument("--host", action="append", help="keep only traffic to this host (repeatable)")
197
+
198
+ sp = command("inspect", cmd_inspect, "read an OpenAPI file or HAR (path or URL); write source.json and digest.md")
199
+ sp.add_argument("source", help="OpenAPI/Swagger file or HAR, path or URL")
200
+ sp.add_argument("--host", action="append", help="keep only traffic to this host (HAR; repeatable)")
201
+ sp.add_argument("--budget", type=int, default=60000, help="digest size limit in characters (default 60000)")
202
+
203
+ sp = command("draft", cmd_draft, "write a heuristic starting plan.yaml from source.json")
204
+ sp.add_argument("--name", help="project name, e.g. petstore-mcp")
205
+ sp.add_argument("--force", action="store_true", help="overwrite an existing plan.yaml")
206
+
207
+ sp = command("design", cmd_design, "let an LLM (API key needed) design or refine plan.yaml")
208
+ sp.add_argument("--goal", default="", help="what the server is for")
209
+ sp.add_argument("--provider", choices=["anthropic", "openai", "openrouter", "ollama"], default="anthropic")
210
+ sp.add_argument("--model")
211
+
212
+ sp = command("validate", cmd_validate, "check plan.yaml; exit 1 on errors")
213
+ sp.add_argument("--plan", help="plan file (default <workdir>/plan.yaml)")
214
+
215
+ sp = command("generate", cmd_generate, "validate plan.yaml, then write the MCP server project")
216
+ sp.add_argument("--plan", help="plan file (default <workdir>/plan.yaml)")
217
+ sp.add_argument("-o", "--output", help="output directory (default ./<plan name>)")
218
+ sp.add_argument("--force", action="store_true", help="write into an existing directory")
219
+
220
+ sp = command("check", cmd_check, "call every tool of a generated project live; exit 1 on failures", workdir=False)
221
+ sp.add_argument("project")
222
+ sp.add_argument("--json", action="store_true")
223
+ sp.add_argument("--writes", action="store_true", help="also call tools that change data (read_only: false)")
224
+
225
+ command("guide", cmd_guide, "print the tool-design rules for plan.yaml", workdir=False)
226
+ command("mcp", cmd_mcp, "run the mcpsmith MCP server on stdio", workdir=False)
227
+ return p
228
+
229
+
230
+ def main(argv: list[str] | None = None) -> int:
231
+ # Digests contain Persian text; the Windows console default would raise on it.
232
+ try:
233
+ sys.stdout.reconfigure(encoding="utf-8")
234
+ except (AttributeError, ValueError):
235
+ pass
236
+ args = build_parser().parse_args(argv)
237
+ try:
238
+ return args.fn(args)
239
+ except EXPECTED as e:
240
+ print(f"mcpsmith: error: {e}", file=sys.stderr)
241
+ return 2
242
+
243
+
244
+ if __name__ == "__main__":
245
+ sys.exit(main())