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.
- machine_internet/__init__.py +0 -0
- machine_internet/ai/__init__.py +0 -0
- machine_internet/ai/client.py +146 -0
- machine_internet/auth/__init__.py +0 -0
- machine_internet/auth/injector.py +102 -0
- machine_internet/cli.py +361 -0
- machine_internet/condensation/__init__.py +0 -0
- machine_internet/condensation/condenser.py +262 -0
- machine_internet/condensation/eval.py +131 -0
- machine_internet/config.py +26 -0
- machine_internet/discovery/__init__.py +0 -0
- machine_internet/discovery/errors.py +60 -0
- machine_internet/discovery/openapi.py +281 -0
- machine_internet/discovery/schema.py +104 -0
- machine_internet/discovery/traffic.py +433 -0
- machine_internet/examples/archive_org.json +127 -0
- machine_internet/examples/bundlephobia.json +200 -0
- machine_internet/examples/caniuse.json +133 -0
- machine_internet/examples/crates_io.json +273 -0
- machine_internet/examples/deps_dev.json +332 -0
- machine_internet/examples/hacker_news.json +86 -0
- machine_internet/examples/huggingface.json +56 -0
- machine_internet/examples/npm_trends.json +154 -0
- machine_internet/examples/ossinsight.json +98 -0
- machine_internet/examples/regex101.json +126 -0
- machine_internet/serving/__init__.py +0 -0
- machine_internet/serving/executor.py +140 -0
- machine_internet/serving/mcp_server.py +158 -0
- machine_internet-0.1.0.dist-info/METADATA +201 -0
- machine_internet-0.1.0.dist-info/RECORD +33 -0
- machine_internet-0.1.0.dist-info/WHEEL +4 -0
- machine_internet-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
machine_internet/cli.py
ADDED
|
@@ -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
|