machine-internet 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.
Files changed (33) hide show
  1. machine_internet/__init__.py +0 -0
  2. machine_internet/ai/__init__.py +0 -0
  3. machine_internet/ai/client.py +146 -0
  4. machine_internet/auth/__init__.py +0 -0
  5. machine_internet/auth/injector.py +102 -0
  6. machine_internet/cli.py +361 -0
  7. machine_internet/condensation/__init__.py +0 -0
  8. machine_internet/condensation/condenser.py +262 -0
  9. machine_internet/condensation/eval.py +131 -0
  10. machine_internet/config.py +26 -0
  11. machine_internet/discovery/__init__.py +0 -0
  12. machine_internet/discovery/errors.py +60 -0
  13. machine_internet/discovery/openapi.py +281 -0
  14. machine_internet/discovery/schema.py +104 -0
  15. machine_internet/discovery/traffic.py +433 -0
  16. machine_internet/examples/archive_org.json +127 -0
  17. machine_internet/examples/bundlephobia.json +200 -0
  18. machine_internet/examples/caniuse.json +133 -0
  19. machine_internet/examples/crates_io.json +273 -0
  20. machine_internet/examples/deps_dev.json +332 -0
  21. machine_internet/examples/hacker_news.json +86 -0
  22. machine_internet/examples/huggingface.json +56 -0
  23. machine_internet/examples/npm_trends.json +154 -0
  24. machine_internet/examples/ossinsight.json +98 -0
  25. machine_internet/examples/regex101.json +126 -0
  26. machine_internet/serving/__init__.py +0 -0
  27. machine_internet/serving/executor.py +140 -0
  28. machine_internet/serving/mcp_server.py +158 -0
  29. machine_internet-0.1.0.dist-info/METADATA +201 -0
  30. machine_internet-0.1.0.dist-info/RECORD +33 -0
  31. machine_internet-0.1.0.dist-info/WHEEL +4 -0
  32. machine_internet-0.1.0.dist-info/entry_points.txt +2 -0
  33. machine_internet-0.1.0.dist-info/licenses/LICENSE +201 -0
File without changes
File without changes
@@ -0,0 +1,146 @@
1
+ """Multi-provider LLM client with automatic fallback on quota and server errors.
2
+
3
+ Uses whichever API key is configured (see config.py). When more than one is
4
+ set, a quota (429), auth (401/403) or server (5xx) error moves on to the next provider.
5
+ Any other error raises immediately.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import sys
10
+ from typing import Optional
11
+
12
+ from ..config import settings
13
+
14
+ _RETRYABLE = (
15
+ "429", "quota", "rate_limit", "rate limit",
16
+ "resource_exhausted", "resource exhausted",
17
+ "500", "502", "503", "529", "unavailable", "overloaded",
18
+ # A bad or revoked key on one provider should not block the others
19
+ "401", "403", "invalid_api_key", "invalid api key", "authentication", "permission",
20
+ )
21
+
22
+
23
+ def _is_retryable(exc: Exception) -> bool:
24
+ msg = str(exc).lower()
25
+ return any(kw in msg for kw in _RETRYABLE)
26
+
27
+
28
+ def _log(msg: str) -> None:
29
+ print(msg, file=sys.stderr)
30
+
31
+
32
+ def _build_agent_list() -> list[dict]:
33
+ agents: list[dict] = []
34
+ if settings.anthropic_api_key:
35
+ agents.append({"provider": "anthropic", "api_key": settings.anthropic_api_key, "model": settings.anthropic_model})
36
+ if settings.openai_api_key:
37
+ agents.append({"provider": "openai", "api_key": settings.openai_api_key, "model": settings.openai_model})
38
+ if settings.gemini_api_key:
39
+ agents.append({"provider": "gemini", "api_key": settings.gemini_api_key, "model": settings.gemini_model})
40
+ for model in settings.gemini_fallback_models.split(","):
41
+ if model.strip():
42
+ agents.append({"provider": "gemini", "api_key": settings.gemini_api_key, "model": model.strip()})
43
+ return agents
44
+
45
+
46
+ async def _call_anthropic(prompt: str, system: Optional[str], max_tokens: int, agent: dict) -> str:
47
+ from anthropic import AsyncAnthropic
48
+
49
+ client = AsyncAnthropic(api_key=agent["api_key"])
50
+ kwargs: dict = {
51
+ "model": agent["model"],
52
+ "max_tokens": max_tokens,
53
+ "messages": [{"role": "user", "content": prompt}],
54
+ # Re-run on a fallback model if the primary model declines the request
55
+ "betas": ["server-side-fallback-2026-07-01"],
56
+ "fallbacks": "default",
57
+ }
58
+ if system:
59
+ kwargs["system"] = system
60
+ # Streamed so large max_tokens values do not hit HTTP timeouts
61
+ async with client.beta.messages.stream(**kwargs) as stream:
62
+ message = await stream.get_final_message()
63
+ if message.stop_reason == "refusal":
64
+ raise RuntimeError("Anthropic model declined the request")
65
+ return "".join(b.text for b in message.content if b.type == "text").strip()
66
+
67
+
68
+ async def _call_openai(prompt: str, system: Optional[str], max_tokens: int, agent: dict) -> str:
69
+ from openai import AsyncOpenAI
70
+
71
+ client = AsyncOpenAI(api_key=agent["api_key"])
72
+ messages = []
73
+ if system:
74
+ messages.append({"role": "system", "content": system})
75
+ messages.append({"role": "user", "content": prompt})
76
+ response = await client.chat.completions.create(
77
+ model=agent["model"],
78
+ messages=messages,
79
+ max_completion_tokens=min(max_tokens, 16384),
80
+ )
81
+ return response.choices[0].message.content.strip()
82
+
83
+
84
+ async def _call_gemini(prompt: str, system: Optional[str], max_tokens: int, agent: dict) -> str:
85
+ from google import genai
86
+ from google.genai import types as genai_types
87
+
88
+ client = genai.Client(api_key=agent["api_key"])
89
+ cfg_kwargs: dict = {"max_output_tokens": max_tokens}
90
+ if system:
91
+ cfg_kwargs["system_instruction"] = system
92
+ response = await client.aio.models.generate_content(
93
+ model=agent["model"],
94
+ contents=prompt,
95
+ config=genai_types.GenerateContentConfig(**cfg_kwargs),
96
+ )
97
+ return response.text.strip()
98
+
99
+
100
+ _CALLERS = {
101
+ "anthropic": _call_anthropic,
102
+ "openai": _call_openai,
103
+ "gemini": _call_gemini,
104
+ }
105
+
106
+
107
+ class NoAPIKeyError(RuntimeError):
108
+ pass
109
+
110
+
111
+ class FallbackAIClient:
112
+ """Calls each configured provider in turn until one succeeds."""
113
+
114
+ def __init__(self) -> None:
115
+ self._agents = _build_agent_list()
116
+ if not self._agents:
117
+ raise NoAPIKeyError(
118
+ "No LLM API key found. Set one of ANTHROPIC_API_KEY, OPENAI_API_KEY or GEMINI_API_KEY."
119
+ )
120
+
121
+ async def generate(self, prompt: str, *, system: Optional[str] = None, max_tokens: int = 32768) -> str:
122
+ last_exc: Optional[Exception] = None
123
+ for i, agent in enumerate(self._agents):
124
+ try:
125
+ result = await _CALLERS[agent["provider"]](prompt, system, max_tokens, agent)
126
+ if i > 0:
127
+ _log(f" [ai] used fallback {agent['provider']}:{agent['model']}")
128
+ return result
129
+ except Exception as exc:
130
+ if _is_retryable(exc):
131
+ last_exc = exc
132
+ _log(f" [ai] {agent['provider']}:{agent['model']} failed ({str(exc)[:120]}), trying next")
133
+ continue
134
+ raise
135
+ raise RuntimeError(f"All {len(self._agents)} LLM provider(s) exhausted (quota or server errors).") from last_exc
136
+
137
+
138
+ _client: Optional[FallbackAIClient] = None
139
+
140
+
141
+ def get_ai_client() -> FallbackAIClient:
142
+ """Return the module-level singleton, constructing it on first call."""
143
+ global _client
144
+ if _client is None:
145
+ _client = FallbackAIClient()
146
+ return _client
File without changes
@@ -0,0 +1,102 @@
1
+ from __future__ import annotations
2
+ import base64
3
+ import time
4
+ from typing import Any, Optional
5
+
6
+ import httpx
7
+
8
+ from ..discovery.schema import AuthScheme
9
+
10
+
11
+ class AuthInjector:
12
+ def __init__(self, auth_type: AuthScheme, credentials: dict[str, Any]) -> None:
13
+ self.auth_type = auth_type
14
+ self.credentials = credentials
15
+ self._token_cache: Optional[str] = None
16
+ self._token_expiry: float = 0.0
17
+
18
+ async def inject(
19
+ self,
20
+ headers: dict[str, str],
21
+ params: dict[str, Any],
22
+ ) -> tuple[dict[str, str], dict[str, Any]]:
23
+ headers = dict(headers)
24
+ params = dict(params)
25
+
26
+ if self.auth_type == AuthScheme.API_KEY:
27
+ location = self.credentials.get("location", "header")
28
+ name = self.credentials.get("name", "X-API-Key")
29
+ value = self.credentials.get("value", "")
30
+ if location == "header":
31
+ headers[name] = value
32
+ else:
33
+ params[name] = value
34
+
35
+ elif self.auth_type == AuthScheme.BEARER:
36
+ headers["Authorization"] = f"Bearer {self.credentials.get('token', '')}"
37
+
38
+ elif self.auth_type == AuthScheme.BASIC:
39
+ u = self.credentials.get("username", "")
40
+ p = self.credentials.get("password", "")
41
+ encoded = base64.b64encode(f"{u}:{p}".encode()).decode()
42
+ headers["Authorization"] = f"Basic {encoded}"
43
+
44
+ elif self.auth_type == AuthScheme.OAUTH2:
45
+ token = await self._oauth2_token()
46
+ if token:
47
+ headers["Authorization"] = f"Bearer {token}"
48
+
49
+ return headers, params
50
+
51
+ async def _oauth2_token(self) -> Optional[str]:
52
+ if self._token_cache and time.time() < self._token_expiry - 60:
53
+ return self._token_cache
54
+
55
+ token_url = self.credentials.get("token_url", "")
56
+ if not token_url:
57
+ return None
58
+
59
+ async with httpx.AsyncClient() as client:
60
+ resp = await client.post(token_url, data={
61
+ "grant_type": "client_credentials",
62
+ "client_id": self.credentials.get("client_id", ""),
63
+ "client_secret": self.credentials.get("client_secret", ""),
64
+ "scope": self.credentials.get("scope", ""),
65
+ })
66
+ resp.raise_for_status()
67
+ data = resp.json()
68
+
69
+ self._token_cache = data.get("access_token")
70
+ self._token_expiry = time.time() + data.get("expires_in", 3600)
71
+ return self._token_cache
72
+
73
+
74
+ def credentials_from_env(auth_type: AuthScheme) -> Optional[dict[str, Any]]:
75
+ """Read credentials for the target API from MI_* environment variables.
76
+
77
+ bearer: MI_TOKEN
78
+ api_key: MI_API_KEY, optional MI_API_KEY_NAME (default X-API-Key), MI_API_KEY_IN (header|query)
79
+ basic: MI_USERNAME, MI_PASSWORD
80
+ oauth2: MI_TOKEN_URL, MI_CLIENT_ID, MI_CLIENT_SECRET, optional MI_SCOPE
81
+ """
82
+ import os
83
+
84
+ env = os.environ.get
85
+ if auth_type == AuthScheme.BEARER and env("MI_TOKEN"):
86
+ return {"token": env("MI_TOKEN")}
87
+ if auth_type == AuthScheme.API_KEY and env("MI_API_KEY"):
88
+ return {
89
+ "value": env("MI_API_KEY"),
90
+ "name": env("MI_API_KEY_NAME", "X-API-Key"),
91
+ "location": env("MI_API_KEY_IN", "header"),
92
+ }
93
+ if auth_type == AuthScheme.BASIC and env("MI_USERNAME"):
94
+ return {"username": env("MI_USERNAME"), "password": env("MI_PASSWORD", "")}
95
+ if auth_type == AuthScheme.OAUTH2 and env("MI_TOKEN_URL"):
96
+ return {
97
+ "token_url": env("MI_TOKEN_URL"),
98
+ "client_id": env("MI_CLIENT_ID", ""),
99
+ "client_secret": env("MI_CLIENT_SECRET", ""),
100
+ "scope": env("MI_SCOPE", ""),
101
+ }
102
+ return None
@@ -0,0 +1,361 @@
1
+ """machine-internet: turn a website into an MCP server.
2
+
3
+ machine-internet wrap https://bundlephobia.com
4
+ machine-internet serve bundlephobia
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import json
10
+ import sys
11
+ import textwrap
12
+ from pathlib import Path
13
+ from urllib.parse import urlparse
14
+
15
+ import click
16
+
17
+ from .discovery.errors import (
18
+ AllTrackersFilteredError,
19
+ BrandFilteredError,
20
+ DiscoveryError,
21
+ LLMEmptySchemaError,
22
+ LLMInvalidResponseError,
23
+ LLMQuotaError,
24
+ NoXHRCapturedError,
25
+ PlaywrightNotInstalledError,
26
+ SpecEmptyError,
27
+ SpecNetworkError,
28
+ SpecParseError,
29
+ TrafficNetworkError,
30
+ )
31
+ from .discovery.schema import CondensedSchema
32
+
33
+ EXAMPLES_DIR = Path(__file__).parent / "examples"
34
+ USER_DIR = Path.home() / ".machine-internet" / "schemas"
35
+ _CONDENSER_CAP = 40
36
+
37
+
38
+ def _fail(*lines: str) -> None:
39
+ for line in lines:
40
+ click.echo(line, err=True)
41
+ sys.exit(1)
42
+
43
+
44
+ def _resolve_schema(name_or_path: str) -> Path:
45
+ """Find a schema by file path, then by name in the user dir, then in bundled examples."""
46
+ p = Path(name_or_path)
47
+ if p.suffix == ".json" and p.exists():
48
+ return p
49
+ for d in (USER_DIR, EXAMPLES_DIR):
50
+ candidate = d / f"{name_or_path}.json"
51
+ if candidate.exists():
52
+ return candidate
53
+ names = sorted({f.stem for d in (USER_DIR, EXAMPLES_DIR) if d.exists() for f in d.glob("*.json")})
54
+ raise click.ClickException(
55
+ f"No schema named '{name_or_path}'. Available: {', '.join(names) or 'none'}. "
56
+ "Run 'machine-internet wrap <url>' to create one."
57
+ )
58
+
59
+
60
+ def _client_config(name: str, target: str | None = None) -> str:
61
+ """MCP client config block. target is what 'serve' receives (a name or a file path)."""
62
+ return json.dumps(
63
+ {"mcpServers": {name: {"command": "uvx", "args": ["machine-internet", "serve", target or name]}}},
64
+ indent=2,
65
+ )
66
+
67
+
68
+ @click.group()
69
+ @click.version_option(package_name="machine-internet")
70
+ def main() -> None:
71
+ """Turn any website into an MCP server. No API needed."""
72
+ # Windows consoles default to cp1252 and crash on API responses containing non-ASCII text
73
+ for stream in (sys.stdout, sys.stderr):
74
+ try:
75
+ stream.reconfigure(encoding="utf-8", errors="replace")
76
+ except (AttributeError, ValueError):
77
+ pass
78
+
79
+
80
+ # --------------------------------------------------------------------------- wrap
81
+
82
+
83
+ @main.command()
84
+ @click.argument("url")
85
+ @click.option("--name", default=None, help="Name for the server (default: inferred from the site)")
86
+ @click.option("--spec", "spec_url", default=None, help="Direct URL to an OpenAPI spec (skips sniffing)")
87
+ @click.option("--tags", default=None, help="Comma-separated OpenAPI tags to keep, e.g. repos,issues")
88
+ @click.option("--sniff", "force_traffic", is_flag=True, help="Skip the OpenAPI check and sniff traffic directly")
89
+ @click.option("--raw", "no_condense", is_flag=True, help="Save the raw discovered schema without condensing")
90
+ @click.option("-o", "--output", default=None, help="Output file (default: ~/.machine-internet/schemas/<name>.json)")
91
+ @click.option("--debug", is_flag=True, help="Show full tracebacks on errors")
92
+ def wrap(url, name, spec_url, tags, force_traffic, no_condense, output, debug):
93
+ """Discover the API behind URL and save it as an MCP server."""
94
+ if "://" not in url:
95
+ url = "https://" + url
96
+ filter_tags = [t.strip() for t in tags.split(",")] if tags else None
97
+
98
+ if not no_condense:
99
+ from .ai.client import NoAPIKeyError, get_ai_client
100
+
101
+ try:
102
+ get_ai_client()
103
+ except NoAPIKeyError:
104
+ _fail(
105
+ "[FAIL] No LLM API key found.",
106
+ "",
107
+ " Set any one of these (environment variable or a .env file in this folder):",
108
+ " ANTHROPIC_API_KEY=...",
109
+ " OPENAI_API_KEY=...",
110
+ " GEMINI_API_KEY=...",
111
+ )
112
+
113
+ try:
114
+ asyncio.run(_wrap(url, name, spec_url, filter_tags, force_traffic, no_condense, output))
115
+ except KeyboardInterrupt:
116
+ _fail("", "Cancelled.")
117
+ except Exception as exc:
118
+ if debug:
119
+ raise
120
+ _fail(f"[FAIL] {type(exc).__name__}: {str(exc)[:300]}", " Re-run with --debug for the full traceback.")
121
+
122
+
123
+ async def _wrap(url, name, spec_url, filter_tags, force_traffic, no_condense, output):
124
+ from .discovery.openapi import discover_openapi, discover_openapi_from_spec_url
125
+ from .discovery.traffic import discover_via_traffic
126
+
127
+ click.echo(f"\nWrapping {url}")
128
+ raw = None
129
+
130
+ # Path A: OpenAPI spec, if one exists (free, no LLM needed for discovery)
131
+ if spec_url or not force_traffic:
132
+ try:
133
+ if spec_url:
134
+ click.echo(f" Fetching spec from {spec_url} ...")
135
+ raw = await discover_openapi_from_spec_url(spec_url, url)
136
+ else:
137
+ click.echo(" Checking for an OpenAPI spec ...")
138
+ raw = await discover_openapi(url)
139
+ if raw:
140
+ click.echo(f" [OK] Found spec: {len(raw.endpoints)} endpoints")
141
+ except SpecNetworkError:
142
+ _fail(f"[FAIL] Could not reach {spec_url or url}. Check the URL is correct and public.")
143
+ except SpecParseError as exc:
144
+ _fail(
145
+ f"[FAIL] Found a spec at {exc.spec_path} but could not parse it.",
146
+ " Supported: OpenAPI 3.x and Swagger 2.x (JSON or YAML). Try --spec <url> or --sniff.",
147
+ )
148
+ except SpecEmptyError as exc:
149
+ _fail(f"[FAIL] Spec at {exc.spec_path} has no usable endpoints. Try --sniff.")
150
+
151
+ # Path B: sniff the API calls the site makes in a headless browser
152
+ if raw is None:
153
+ click.echo(" Sniffing network traffic in a headless browser ...")
154
+ parsed = urlparse(url)
155
+ base = f"{parsed.scheme}://{parsed.netloc}"
156
+ try:
157
+ raw = await discover_via_traffic(url)
158
+ click.echo(f" [OK] Inferred {len(raw.endpoints)} endpoints")
159
+ except PlaywrightNotInstalledError:
160
+ _fail("[FAIL] Could not install headless Chromium. Run: playwright install chromium")
161
+ except TrafficNetworkError:
162
+ _fail(f"[FAIL] Could not load {url}. Check the URL is correct and public.")
163
+ except NoXHRCapturedError:
164
+ _fail(
165
+ "[FAIL] The page made no API calls while loading.",
166
+ " It is probably server-rendered, so there is no hidden API to find.",
167
+ " If the site has search or filters, try a page that loads data as you use it:",
168
+ f" machine-internet wrap \"{base}/search?q=test\"",
169
+ f" machine-internet wrap {base}/items/1",
170
+ )
171
+ except AllTrackersFilteredError:
172
+ _fail(
173
+ "[FAIL] The page made no API calls of its own, only analytics, ads or bot checks.",
174
+ " It is probably server-rendered or behind bot protection.",
175
+ f" If it has search or filters, try that page, e.g. machine-internet wrap \"{base}/search?q=test\"",
176
+ )
177
+ except BrandFilteredError as exc:
178
+ _fail(
179
+ f"[FAIL] API calls went to other domains ({', '.join(exc.seen_brands[:5])}).",
180
+ f" Try the API domain directly: machine-internet wrap https://api.{exc.base_brand}.com",
181
+ )
182
+ except (LLMInvalidResponseError, LLMEmptySchemaError):
183
+ _fail(
184
+ "[FAIL] Traffic was captured, but the LLM could not turn it into endpoints.",
185
+ " Run again (LLM output varies), or point at a page with less going on.",
186
+ )
187
+ except LLMQuotaError:
188
+ _fail("[FAIL] LLM quota exhausted on every configured provider. Add another key or retry later.")
189
+ except DiscoveryError:
190
+ _fail("[FAIL] Discovery failed.")
191
+
192
+ if filter_tags:
193
+ before = len(raw.endpoints)
194
+ wanted = {t.lower() for t in filter_tags}
195
+ raw.endpoints = [ep for ep in raw.endpoints if any(t.lower() in wanted for t in ep.tags)]
196
+ click.echo(f" Filtered {before} -> {len(raw.endpoints)} endpoints by tag")
197
+
198
+ if len(raw.endpoints) > _CONDENSER_CAP:
199
+ click.echo(f" [WARN] {len(raw.endpoints)} endpoints; only the first {_CONDENSER_CAP} are condensed. Use --tags to focus.")
200
+
201
+ if no_condense:
202
+ data = raw.model_dump()
203
+ default_name = raw.title.lower().replace(" ", "_")
204
+ else:
205
+ from .condensation.condenser import condense
206
+
207
+ click.echo(" Condensing into agent tools ...")
208
+ try:
209
+ condensed = await condense(raw, source_url=url)
210
+ except LLMQuotaError:
211
+ _fail("[FAIL] LLM quota exhausted on every configured provider. Add another key or retry later.")
212
+ except (LLMInvalidResponseError, LLMEmptySchemaError):
213
+ _fail("[FAIL] The LLM did not return usable tools. Run again, or narrow it with --tags.")
214
+ if name:
215
+ condensed.service_name = name
216
+ click.echo(f" [OK] {len(condensed.tools)} tools:")
217
+ for t in condensed.tools:
218
+ click.echo(f" {t.name}: {textwrap.shorten(t.description, 78, placeholder=' ...')}")
219
+ data = json.loads(condensed.model_dump_json())
220
+ data["discovery_method"] = raw.discovery_method
221
+ default_name = condensed.service_name
222
+
223
+ final_name = name or default_name
224
+ out = Path(output) if output else USER_DIR / f"{final_name}.json"
225
+ out.parent.mkdir(parents=True, exist_ok=True)
226
+ out.write_text(json.dumps(data, indent=2), encoding="utf-8")
227
+
228
+ click.echo(f"\nSaved {out}")
229
+ if not no_condense:
230
+ click.echo("\nAdd this to your MCP client config (Claude Desktop, Cursor, ...):\n")
231
+ target = str(out.resolve()) if output else final_name
232
+ click.echo(_client_config(final_name, target))
233
+ click.echo(f"\nOr try it now: machine-internet serve {target}")
234
+
235
+
236
+ # --------------------------------------------------------------------------- serve
237
+
238
+
239
+ @main.command()
240
+ @click.argument("schema")
241
+ @click.option("--sse", is_flag=True, help="Serve over HTTP/SSE instead of stdio")
242
+ @click.option("--host", default="127.0.0.1", show_default=True, help="Bind host (with --sse)")
243
+ @click.option("--port", default=8000, show_default=True, help="Bind port (with --sse)")
244
+ def serve(schema, sse, host, port):
245
+ """Run an MCP server for SCHEMA (a name from 'list', or a .json path).
246
+
247
+ Defaults to stdio, which is what Claude Desktop and Cursor launch.
248
+ Credentials for APIs that need them are read from MI_TOKEN, MI_API_KEY,
249
+ MI_USERNAME/MI_PASSWORD or MI_CLIENT_ID/MI_CLIENT_SECRET/MI_TOKEN_URL.
250
+ """
251
+ from .serving.mcp_server import serve_sse, serve_stdio
252
+
253
+ path = _resolve_schema(schema)
254
+ condensed = CondensedSchema(**json.loads(path.read_text(encoding="utf-8")))
255
+ # stdout carries the MCP protocol in stdio mode, so all logging goes to stderr
256
+ click.echo(f"{condensed.service_name}: {len(condensed.tools)} tools from {path}", err=True)
257
+ if sse:
258
+ click.echo(f"MCP endpoint: http://{host}:{port}/mcp", err=True)
259
+ serve_sse(condensed, host=host, port=port)
260
+ else:
261
+ serve_stdio(condensed)
262
+
263
+
264
+ # --------------------------------------------------------------------------- list / config
265
+
266
+
267
+ @main.command("list")
268
+ def list_schemas():
269
+ """List bundled examples and the servers you have wrapped."""
270
+ for label, d in (("Your servers", USER_DIR), ("Bundled examples", EXAMPLES_DIR)):
271
+ files = sorted(d.glob("*.json")) if d.exists() else []
272
+ click.echo(f"\n{label}:")
273
+ if not files:
274
+ click.echo(" (none yet: machine-internet wrap <url>)")
275
+ for f in files:
276
+ try:
277
+ data = json.loads(f.read_text(encoding="utf-8"))
278
+ src = data.get("source_url") or data.get("base_url", "")
279
+ click.echo(f" {f.stem:<20} {len(data.get('tools', [])):>2} tools {src}")
280
+ except Exception:
281
+ click.echo(f" {f.stem:<20} (unreadable)")
282
+ click.echo()
283
+
284
+
285
+ def _coerce(value: str, typ: str):
286
+ """Turn a key=value string from the command line into the tool parameter's type."""
287
+ try:
288
+ if typ == "integer":
289
+ return int(value)
290
+ if typ == "number":
291
+ return float(value)
292
+ if typ == "boolean":
293
+ return value.lower() in ("1", "true", "yes", "y")
294
+ if typ in ("object", "array"):
295
+ return json.loads(value)
296
+ except (ValueError, json.JSONDecodeError):
297
+ pass
298
+ return value
299
+
300
+
301
+ @main.command()
302
+ @click.argument("schema")
303
+ def tools(schema):
304
+ """Show the tools in SCHEMA and their parameters."""
305
+ s = CondensedSchema(**json.loads(_resolve_schema(schema).read_text(encoding="utf-8")))
306
+ click.echo(f"\n{s.service_name} ({s.base_url})\n")
307
+ for t in s.tools:
308
+ click.echo(click.style(t.name, bold=True))
309
+ click.echo(f" {t.description}")
310
+ for p in t.parameters:
311
+ req = "required" if p.required else "optional"
312
+ click.echo(f" {p.name} ({p.type}, {req}): {p.description}")
313
+ click.echo()
314
+
315
+
316
+ @main.command()
317
+ @click.argument("schema")
318
+ @click.argument("tool")
319
+ @click.argument("args", nargs=-1)
320
+ def call(schema, tool, args):
321
+ """Call one TOOL directly, e.g.: machine-internet call bundlephobia get_package_size package=react"""
322
+ from .auth.injector import AuthInjector, credentials_from_env
323
+ from .serving.executor import ToolExecutor
324
+
325
+ s = CondensedSchema(**json.loads(_resolve_schema(schema).read_text(encoding="utf-8")))
326
+ t = next((x for x in s.tools if x.name == tool), None)
327
+ if t is None:
328
+ raise click.ClickException(f"No tool '{tool}'. Available: {', '.join(x.name for x in s.tools)}")
329
+
330
+ types_by_name = {p.name: p.type for p in t.parameters}
331
+ arguments = {}
332
+ for a in args:
333
+ if "=" not in a:
334
+ raise click.ClickException(f"Arguments look like key=value, got '{a}'")
335
+ k, v = a.split("=", 1)
336
+ arguments[k] = _coerce(v, types_by_name.get(k, "string"))
337
+ missing = [p.name for p in t.parameters if p.required and p.name not in arguments]
338
+ if missing:
339
+ raise click.ClickException(f"Missing required argument(s): {', '.join(f'{m}=...' for m in missing)}")
340
+
341
+ injector = None
342
+ creds = credentials_from_env(s.auth_type) if s.auth_type.value != "none" else None
343
+ if creds:
344
+ injector = AuthInjector(s.auth_type, creds)
345
+ try:
346
+ result = asyncio.run(ToolExecutor(s.base_url, injector, s.static_headers, s.static_query).execute(t, arguments))
347
+ except Exception as exc:
348
+ raise click.ClickException(f"{type(exc).__name__}: {exc}")
349
+ click.echo(json.dumps(result, indent=2, ensure_ascii=False) if isinstance(result, (dict, list)) else str(result))
350
+
351
+
352
+ @main.command()
353
+ @click.argument("schema")
354
+ def config(schema):
355
+ """Print the MCP client config block for SCHEMA."""
356
+ _resolve_schema(schema)
357
+ click.echo(_client_config(schema))
358
+
359
+
360
+ if __name__ == "__main__":
361
+ main()
File without changes