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 +3 -0
- mcpsmith/capture.py +49 -0
- mcpsmith/check.py +87 -0
- mcpsmith/cli.py +245 -0
- mcpsmith/codegen.py +514 -0
- mcpsmith/design/__init__.py +18 -0
- mcpsmith/design/_digest.py +135 -0
- mcpsmith/design/_draft.py +238 -0
- mcpsmith/design/_validate.py +195 -0
- mcpsmith/design/guide.md +115 -0
- mcpsmith/design/llm.py +169 -0
- mcpsmith/importers/__init__.py +34 -0
- mcpsmith/importers/har.py +436 -0
- mcpsmith/importers/openapi.py +279 -0
- mcpsmith/model.py +301 -0
- mcpsmith/runtime/__init__.py +7 -0
- mcpsmith/runtime/http.py +343 -0
- mcpsmith/runtime/server.py +79 -0
- mcpsmith/runtime/shape.py +55 -0
- mcpsmith/server.py +171 -0
- mcpsmith/templates/ci.yml +21 -0
- mcpsmith/templates/conftest.py.tmpl +46 -0
- mcpsmith/templates/gitignore +8 -0
- mcpsmith/templates/publish.yml +51 -0
- mcpsmith-0.1.0.dist-info/METADATA +193 -0
- mcpsmith-0.1.0.dist-info/RECORD +29 -0
- mcpsmith-0.1.0.dist-info/WHEEL +4 -0
- mcpsmith-0.1.0.dist-info/entry_points.txt +2 -0
- mcpsmith-0.1.0.dist-info/licenses/LICENSE +21 -0
mcpsmith/__init__.py
ADDED
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())
|