protogrid-sdk 0.2.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.
protogrid/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """protogrid: find MCP servers by intent, get a connection block, connect with no human in the loop."""
2
+ from .client import DEFAULT_BASE_URL, AsyncProtogridClient, ProtogridClient, ProtogridError
3
+ from .connect import Connectable, OAuthOptions, afind_connectable, find_connectable, open_session, resolve_entry, secrets_satisfied
4
+ from .formatters import to_fastmcp_transport, to_mcp_servers, to_pydantic_ai
5
+ from .oauth import ConsentHandler, FileTokenStore, MemoryTokenStore, TokenStore, has_tokens, loopback_consent, manual_consent, oauth_provider
6
+ from .secrets import MissingSecretsError, placeholders_in, substitute_secrets
7
+ from .types import META_NS, ConnectionClass, ConnectionResponse, ConnectionTarget, Descriptor, ListToolsResponse, SearchResponse, SearchResult, TrustFlag
8
+
9
+ __all__ = [
10
+ "DEFAULT_BASE_URL", "AsyncProtogridClient", "ProtogridClient", "ProtogridError",
11
+ "Connectable", "OAuthOptions", "afind_connectable", "find_connectable", "open_session", "resolve_entry", "secrets_satisfied",
12
+ "to_fastmcp_transport", "to_mcp_servers", "to_pydantic_ai",
13
+ "ConsentHandler", "FileTokenStore", "MemoryTokenStore", "TokenStore", "has_tokens", "loopback_consent", "manual_consent", "oauth_provider",
14
+ "MissingSecretsError", "placeholders_in", "substitute_secrets",
15
+ "META_NS", "ConnectionClass", "ConnectionResponse", "ConnectionTarget", "Descriptor", "ListToolsResponse", "SearchResponse", "SearchResult", "TrustFlag",
16
+ ]
17
+ __version__ = "0.2.0"
protogrid/client.py ADDED
@@ -0,0 +1,176 @@
1
+ """Sync and async clients for the registry REST API (one method per operation)."""
2
+ from __future__ import annotations
3
+
4
+ from collections.abc import Sequence
5
+ from typing import Any
6
+
7
+ import os
8
+
9
+ import httpx
10
+
11
+ from .types import ConnectionResponse, ConnectionTarget, Descriptor, ListToolsResponse, SearchResponse, ToolEntry
12
+
13
+
14
+ #: The public registry. Pass ``base_url="http://localhost:8080"`` for a self-hosted or local stack.
15
+ DEFAULT_BASE_URL = "https://api.protogrid.dev"
16
+
17
+
18
+ class ProtogridError(Exception):
19
+ def __init__(self, status: int, body: dict[str, Any] | None, retry_after: float | None = None):
20
+ self.status = status
21
+ self.body = body or {}
22
+ self.retry_after = retry_after
23
+ super().__init__(self.body.get("message") or self.body.get("error") or f"HTTP {status}")
24
+
25
+ @property
26
+ def code(self) -> str:
27
+ return str(self.body.get("error") or f"http_{self.status}")
28
+
29
+
30
+ def _search_params(q: str, limit: int | None, class_: Sequence[str] | None, transport: str | None, category: str | None, min_trust: int | None, flags: Sequence[str] | None, exclude_flags: Sequence[str] | None) -> dict[str, str]:
31
+ p: dict[str, str] = {"q": q}
32
+ if limit is not None:
33
+ p["limit"] = str(limit)
34
+ if class_:
35
+ p["class"] = ",".join(class_)
36
+ if transport:
37
+ p["transport"] = transport
38
+ if category:
39
+ p["category"] = category
40
+ if min_trust is not None:
41
+ p["min_trust"] = str(min_trust)
42
+ if flags:
43
+ p["flags"] = ",".join(flags)
44
+ if exclude_flags:
45
+ p["exclude_flags"] = ",".join(exclude_flags)
46
+ return p
47
+
48
+
49
+ def _raise_for(res: httpx.Response) -> None:
50
+ if res.is_success:
51
+ return
52
+ try:
53
+ body = res.json()
54
+ except ValueError:
55
+ body = {"error": f"http_{res.status_code}", "message": res.text[:200]}
56
+ ra = res.headers.get("retry-after")
57
+ raise ProtogridError(res.status_code, body, float(ra) if ra else None)
58
+
59
+
60
+ class _Base:
61
+ def __init__(self, base_url: str = DEFAULT_BASE_URL, *, api_key: str | None = None, timeout: float = 15.0, user_agent: str | None = None):
62
+ self.base_url = base_url.rstrip("/")
63
+ # Defaults to PROTOGRID_API_KEY; pass api_key="" to send none. Keys: https://protogrid.dev/account
64
+ if api_key is None:
65
+ api_key = os.environ.get("PROTOGRID_API_KEY")
66
+ headers = {"accept": "application/json"}
67
+ if api_key:
68
+ headers["authorization"] = f"Bearer {api_key}"
69
+ if user_agent:
70
+ headers["user-agent"] = user_agent
71
+ self._headers = headers
72
+ self._timeout = timeout
73
+
74
+ @staticmethod
75
+ def _server_path(name: str, suffix: str = "") -> str:
76
+ from urllib.parse import quote
77
+
78
+ return f"/v1/servers/{quote(name, safe='')}{suffix}"
79
+
80
+
81
+ class ProtogridClient(_Base):
82
+ """Synchronous client."""
83
+
84
+ def __init__(self, base_url: str = DEFAULT_BASE_URL, *, api_key: str | None = None, timeout: float = 15.0, user_agent: str | None = None, transport: httpx.BaseTransport | None = None):
85
+ super().__init__(base_url, api_key=api_key, timeout=timeout, user_agent=user_agent)
86
+ self._http = httpx.Client(base_url=self.base_url, headers=self._headers, timeout=timeout, transport=transport)
87
+
88
+ def close(self) -> None:
89
+ self._http.close()
90
+
91
+ def __enter__(self) -> ProtogridClient:
92
+ return self
93
+
94
+ def __exit__(self, *exc: object) -> None:
95
+ self.close()
96
+
97
+ def _get(self, path: str, params: dict[str, str] | None = None) -> Any:
98
+ res = self._http.get(path, params=params)
99
+ _raise_for(res)
100
+ return res.json()
101
+
102
+ def search(self, q: str, *, limit: int | None = None, class_: Sequence[str] | None = None, transport: str | None = None, category: str | None = None, min_trust: int | None = None, flags: Sequence[str] | None = None, exclude_flags: Sequence[str] | None = None) -> SearchResponse:
103
+ return self._get("/v1/search", _search_params(q, limit, class_, transport, category, min_trust, flags, exclude_flags))
104
+
105
+ def get_server(self, name: str, *, schemas: bool = False) -> Descriptor:
106
+ return self._get(self._server_path(name), {"schemas": "true"} if schemas else None)
107
+
108
+ def list_tools(self, name: str, *, limit: int | None = None, cursor: str | None = None) -> ListToolsResponse:
109
+ p: dict[str, str] = {}
110
+ if limit is not None:
111
+ p["limit"] = str(limit)
112
+ if cursor:
113
+ p["cursor"] = cursor
114
+ return self._get(self._server_path(name, "/tools"), p or None)
115
+
116
+ def list_all_tools(self, name: str) -> list[ToolEntry]:
117
+ out: list[ToolEntry] = []
118
+ cursor: str | None = None
119
+ while True:
120
+ page = self.list_tools(name, limit=100, cursor=cursor)
121
+ out.extend(page["tools"])
122
+ cursor = page.get("next_cursor")
123
+ if not cursor:
124
+ return out
125
+
126
+ def get_connection(self, name: str, target: ConnectionTarget = "mcpServers") -> ConnectionResponse:
127
+ return self._get(self._server_path(name, "/connection"), {"target": target})
128
+
129
+
130
+ class AsyncProtogridClient(_Base):
131
+ """Asynchronous client (same methods, awaitable)."""
132
+
133
+ def __init__(self, base_url: str = DEFAULT_BASE_URL, *, api_key: str | None = None, timeout: float = 15.0, user_agent: str | None = None, transport: httpx.AsyncBaseTransport | None = None):
134
+ super().__init__(base_url, api_key=api_key, timeout=timeout, user_agent=user_agent)
135
+ self._http = httpx.AsyncClient(base_url=self.base_url, headers=self._headers, timeout=timeout, transport=transport)
136
+
137
+ async def aclose(self) -> None:
138
+ await self._http.aclose()
139
+
140
+ async def __aenter__(self) -> AsyncProtogridClient:
141
+ return self
142
+
143
+ async def __aexit__(self, *exc: object) -> None:
144
+ await self.aclose()
145
+
146
+ async def _get(self, path: str, params: dict[str, str] | None = None) -> Any:
147
+ res = await self._http.get(path, params=params)
148
+ _raise_for(res)
149
+ return res.json()
150
+
151
+ async def search(self, q: str, *, limit: int | None = None, class_: Sequence[str] | None = None, transport: str | None = None, category: str | None = None, min_trust: int | None = None, flags: Sequence[str] | None = None, exclude_flags: Sequence[str] | None = None) -> SearchResponse:
152
+ return await self._get("/v1/search", _search_params(q, limit, class_, transport, category, min_trust, flags, exclude_flags))
153
+
154
+ async def get_server(self, name: str, *, schemas: bool = False) -> Descriptor:
155
+ return await self._get(self._server_path(name), {"schemas": "true"} if schemas else None)
156
+
157
+ async def list_tools(self, name: str, *, limit: int | None = None, cursor: str | None = None) -> ListToolsResponse:
158
+ p: dict[str, str] = {}
159
+ if limit is not None:
160
+ p["limit"] = str(limit)
161
+ if cursor:
162
+ p["cursor"] = cursor
163
+ return await self._get(self._server_path(name, "/tools"), p or None)
164
+
165
+ async def list_all_tools(self, name: str) -> list[ToolEntry]:
166
+ out: list[ToolEntry] = []
167
+ cursor: str | None = None
168
+ while True:
169
+ page = await self.list_tools(name, limit=100, cursor=cursor)
170
+ out.extend(page["tools"])
171
+ cursor = page.get("next_cursor")
172
+ if not cursor:
173
+ return out
174
+
175
+ async def get_connection(self, name: str, target: ConnectionTarget = "mcpServers") -> ConnectionResponse:
176
+ return await self._get(self._server_path(name, "/connection"), {"target": target})
protogrid/connect.py ADDED
@@ -0,0 +1,146 @@
1
+ """From a connection response to a live MCP session (official ``mcp`` package, optional)."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from collections.abc import AsyncIterator, Mapping, Sequence
6
+ from contextlib import asynccontextmanager
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+ from .client import AsyncProtogridClient, ProtogridClient, ProtogridError
11
+ from .oauth import ConsentHandler, TokenStore, has_tokens, oauth_provider
12
+ from .secrets import placeholders_in, substitute_secrets
13
+ from .types import ConnectionResponse, McpServersEntry, SearchResult
14
+
15
+ Secrets = Mapping[str, str | None]
16
+
17
+
18
+ def resolve_entry(conn: ConnectionResponse, secrets: Secrets | None = None, *, partial: bool = False) -> tuple[str, McpServersEntry]:
19
+ """The single ``mcpServers`` entry of a connection response, with secrets substituted."""
20
+ key = conn["key"]
21
+ servers = conn["connection"]["mcpServers"]
22
+ entry = servers.get(key) or next(iter(servers.values()), None)
23
+ if entry is None:
24
+ raise ValueError(f"connection for {conn['server']} has no mcpServers entry")
25
+ return key, substitute_secrets(entry, secrets or {}, partial=partial)
26
+
27
+
28
+ def secrets_satisfied(conn: ConnectionResponse, secrets: Secrets | None = None) -> bool:
29
+ """True when every placeholder the connection needs has a value in ``secrets``."""
30
+ s = secrets or {}
31
+ return all(s.get(n) is not None for n in placeholders_in(conn["connection"]))
32
+
33
+
34
+ @dataclass
35
+ class OAuthOptions:
36
+ store: TokenStore
37
+ consent: ConsentHandler
38
+ client_name: str = "protogrid-sdk agent"
39
+ scope: str | None = None
40
+ client_metadata_url: str | None = None
41
+
42
+
43
+ @dataclass
44
+ class Connectable:
45
+ result: SearchResult
46
+ connection: ConnectionResponse
47
+
48
+
49
+ def _classes(class_: Sequence[str] | None, allow_local: bool, token_store: TokenStore | None) -> list[str]:
50
+ if class_:
51
+ return list(class_)
52
+ return ["R0", "R1", *(["R2"] if token_store else []), *(["L0"] if allow_local else [])]
53
+
54
+
55
+ def _accept(result: SearchResult, conn: ConnectionResponse, secrets: Secrets | None, allow_local: bool, token_store: TokenStore | None) -> bool:
56
+ if conn.get("kind") == "bundle":
57
+ return False
58
+ r2_ok = result["connection_class"] == "R2" and token_store is not None and has_tokens(token_store, result["name"])
59
+ if not result["autonomous"] and not r2_ok and not (allow_local and result["connection_class"] == "L0"):
60
+ return False
61
+ return secrets_satisfied(conn, secrets)
62
+
63
+
64
+ def find_connectable(client: ProtogridClient, q: str, *, secrets: Secrets | None = None, allow_local: bool = False, token_store: TokenStore | None = None, limit: int = 10, class_: Sequence[str] | None = None, **search: Any) -> Connectable | None:
65
+ """First search hit an agent can connect to now: R0, R1 with secrets present, R2 with stored tokens."""
66
+ res = client.search(q, limit=limit, class_=_classes(class_, allow_local, token_store), **search)
67
+ for result in res["results"]:
68
+ if not result["autonomous"] and result["connection_class"] not in ("R2", "L0"):
69
+ continue
70
+ try:
71
+ conn = client.get_connection(result["name"])
72
+ except ProtogridError:
73
+ continue
74
+ if _accept(result, conn, secrets, allow_local, token_store):
75
+ return Connectable(result, conn)
76
+ return None
77
+
78
+
79
+ async def afind_connectable(client: AsyncProtogridClient, q: str, *, secrets: Secrets | None = None, allow_local: bool = False, token_store: TokenStore | None = None, limit: int = 10, class_: Sequence[str] | None = None, **search: Any) -> Connectable | None:
80
+ res = await client.search(q, limit=limit, class_=_classes(class_, allow_local, token_store), **search)
81
+ for result in res["results"]:
82
+ if not result["autonomous"] and result["connection_class"] not in ("R2", "L0"):
83
+ continue
84
+ try:
85
+ conn = await client.get_connection(result["name"])
86
+ except ProtogridError:
87
+ continue
88
+ if _accept(result, conn, secrets, allow_local, token_store):
89
+ return Connectable(result, conn)
90
+ return None
91
+
92
+
93
+ @asynccontextmanager
94
+ async def open_session(conn: ConnectionResponse, secrets: Secrets | None = None, *, oauth: OAuthOptions | None = None, client_name: str = "protogrid-sdk", **session_kwargs: Any) -> AsyncIterator[Any]:
95
+ """Async context manager yielding an initialized ``mcp.ClientSession`` for the preferred remote or package.
96
+
97
+ Streamable HTTP, SSE and stdio are supported. For R2 servers pass ``oauth``; the one-time
98
+ consent runs inside the first request, later runs use the stored tokens.
99
+ """
100
+ from mcp import ClientSession
101
+ from mcp.types import Implementation
102
+
103
+ if conn.get("kind") == "bundle":
104
+ raise ValueError(f"{conn['server']} is only available as a bundle; no transport can be built")
105
+ use_oauth = oauth is not None and conn.get("kind") == "remote" and conn.get("auth_type") in ("oauth2", "unknown")
106
+ _, entry = resolve_entry(conn, secrets, partial=use_oauth)
107
+ from . import __version__ # at call time: the package __init__ imports this module
108
+
109
+ info = Implementation(name=client_name, version=__version__)
110
+
111
+ if "url" in entry:
112
+ headers = dict(entry.get("headers") or {})
113
+ auth = None
114
+ if use_oauth:
115
+ assert oauth is not None
116
+ # The provider sets Authorization itself; a declared `${TOKEN}` placeholder must not block it.
117
+ for k in [k for k, v in headers.items() if k.lower() == "authorization" and placeholders_in(v)]:
118
+ del headers[k]
119
+ auth = oauth_provider(conn["server"], entry["url"], store=oauth.store, consent=oauth.consent, client_name=oauth.client_name, scope=oauth.scope, client_metadata_url=oauth.client_metadata_url)
120
+ if entry.get("type") == "sse":
121
+ from mcp.client.sse import sse_client
122
+
123
+ async with sse_client(entry["url"], headers=headers, auth=auth) as (read, write):
124
+ async with ClientSession(read, write, client_info=info, **session_kwargs) as session:
125
+ await session.initialize()
126
+ yield session
127
+ return
128
+ from mcp.client.streamable_http import create_mcp_http_client, streamable_http_client
129
+
130
+ http = create_mcp_http_client(headers=headers, auth=auth)
131
+ async with http:
132
+ async with streamable_http_client(entry["url"], http_client=http) as (read, write):
133
+ async with ClientSession(read, write, client_info=info, **session_kwargs) as session:
134
+ await session.initialize()
135
+ yield session
136
+ return
137
+
138
+ from mcp.client.stdio import StdioServerParameters, stdio_client
139
+
140
+ env = {k: v for k, v in os.environ.items()}
141
+ env.update(entry.get("env") or {})
142
+ params = StdioServerParameters(command=entry["command"], args=list(entry.get("args") or []), env=env)
143
+ async with stdio_client(params) as (read, write):
144
+ async with ClientSession(read, write, client_info=info, **session_kwargs) as session:
145
+ await session.initialize()
146
+ yield session
@@ -0,0 +1,42 @@
1
+ """Pure formatters connection → framework input (design rule: a formatter is a pure function,
2
+ no framework imported by the core). ``to_pydantic_ai`` imports PydanticAI lazily."""
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from .connect import Secrets, resolve_entry
8
+ from .types import ConnectionResponse
9
+
10
+
11
+ def to_mcp_servers(conn: ConnectionResponse, secrets: Secrets | None = None) -> dict[str, Any]:
12
+ """Generic ``{"mcpServers": {key: entry}}`` block with secrets substituted."""
13
+ key, entry = resolve_entry(conn, secrets)
14
+ return {"mcpServers": {key: entry}}
15
+
16
+
17
+ def to_fastmcp_transport(conn: ConnectionResponse, secrets: Secrets | None = None) -> dict[str, Any]:
18
+ """Constructor arguments for a FastMCP client transport (what PydanticAI's ``MCPToolset`` uses).
19
+
20
+ Returns ``{"kind": "streamable-http" | "sse", "url", "headers"}`` or ``{"kind": "stdio", "command", "args", "env"}``.
21
+ """
22
+ _, entry = resolve_entry(conn, secrets)
23
+ if "url" in entry:
24
+ return {"kind": "sse" if entry.get("type") == "sse" else "streamable-http", "url": entry["url"], "headers": dict(entry.get("headers") or {})}
25
+ return {"kind": "stdio", "command": entry["command"], "args": list(entry.get("args") or []), "env": dict(entry.get("env") or {})}
26
+
27
+
28
+ def to_pydantic_ai(conn: ConnectionResponse, secrets: Secrets | None = None, *, auth: Any = None, **toolset_kwargs: Any) -> Any:
29
+ """A PydanticAI ``MCPToolset`` for the preferred remote or package (requires ``pydantic-ai``).
30
+
31
+ ``auth`` may be an ``httpx.Auth`` (e.g. :func:`protogrid.oauth.oauth_provider`) for R2 servers.
32
+ """
33
+ from pydantic_ai.mcp import MCPToolset, SSETransport, StdioTransport, StreamableHttpTransport
34
+
35
+ t = to_fastmcp_transport(conn, secrets)
36
+ if t["kind"] == "stdio":
37
+ transport: Any = StdioTransport(t["command"], t["args"], env=t["env"] or None)
38
+ elif t["kind"] == "sse":
39
+ transport = SSETransport(t["url"], headers=t["headers"] or None, auth=auth)
40
+ else:
41
+ transport = StreamableHttpTransport(t["url"], headers=t["headers"] or None, auth=auth)
42
+ return MCPToolset(transport, id=conn["key"], **toolset_kwargs)
protogrid/oauth.py ADDED
@@ -0,0 +1,191 @@
1
+ """OAuth for R2 servers (D4: the agent holds the tokens; the registry only exposes metadata).
2
+
3
+ The official ``mcp`` package runs the whole client flow (protected-resource and
4
+ authorization-server discovery, dynamic registration or Client ID Metadata Documents, PKCE,
5
+ refresh) inside its ``OAuthClientProvider``, an ``httpx.Auth``. This module supplies that
6
+ provider from a pluggable :class:`TokenStore` and the single human step, the one-time consent.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import json
12
+ import os
13
+ import sys
14
+ import threading
15
+ from collections.abc import Awaitable, Callable
16
+ from dataclasses import dataclass
17
+ from http.server import BaseHTTPRequestHandler, HTTPServer
18
+ from pathlib import Path
19
+ from typing import Any, Protocol
20
+ from urllib.parse import parse_qs, urlparse
21
+
22
+
23
+ class TokenStore(Protocol):
24
+ """Minimal key/value store; keys are ``"<server name>:<tokens|client>"``. Bring your own."""
25
+
26
+ def get(self, key: str) -> Any | None: ...
27
+ def set(self, key: str, value: Any) -> None: ...
28
+ def delete(self, key: str) -> None: ...
29
+
30
+
31
+ class MemoryTokenStore:
32
+ def __init__(self) -> None:
33
+ self._d: dict[str, Any] = {}
34
+
35
+ def get(self, key: str) -> Any | None:
36
+ return self._d.get(key)
37
+
38
+ def set(self, key: str, value: Any) -> None:
39
+ self._d[key] = value
40
+
41
+ def delete(self, key: str) -> None:
42
+ self._d.pop(key, None)
43
+
44
+
45
+ class FileTokenStore:
46
+ """One JSON file, mode 0600. Fine for a single agent process; not for shared hosts."""
47
+
48
+ def __init__(self, path: str | os.PathLike[str]):
49
+ self.path = Path(path)
50
+
51
+ def _read(self) -> dict[str, Any]:
52
+ try:
53
+ return json.loads(self.path.read_text())
54
+ except (OSError, ValueError):
55
+ return {}
56
+
57
+ def _write(self, d: dict[str, Any]) -> None:
58
+ self.path.write_text(json.dumps(d, indent=2))
59
+ os.chmod(self.path, 0o600)
60
+
61
+ def get(self, key: str) -> Any | None:
62
+ return self._read().get(key)
63
+
64
+ def set(self, key: str, value: Any) -> None:
65
+ d = self._read()
66
+ d[key] = value
67
+ self._write(d)
68
+
69
+ def delete(self, key: str) -> None:
70
+ d = self._read()
71
+ d.pop(key, None)
72
+ self._write(d)
73
+
74
+
75
+ def has_tokens(store: TokenStore, server_name: str) -> bool:
76
+ """True when the store already holds tokens for ``server_name`` (no human step needed now)."""
77
+ return store.get(f"{server_name}:tokens") is not None
78
+
79
+
80
+ # ---------- consent ----------
81
+
82
+
83
+ @dataclass
84
+ class ConsentHandler:
85
+ """How the one-time consent happens: show the URL, then hand back the code and state."""
86
+
87
+ redirect_url: str
88
+ on_authorization_url: Callable[[str], Awaitable[None]]
89
+ wait_for_code: Callable[[], Awaitable[tuple[str, str | None]]]
90
+ close: Callable[[], None] | None = None
91
+
92
+
93
+ def loopback_consent(*, host: str = "127.0.0.1", port: int = 0, path: str = "/callback", on_authorization_url: Callable[[str], Awaitable[None]] | None = None, timeout: float = 300.0, success_html: str = "<!doctype html><title>Authorized</title><p>Authorization received. You can close this tab.</p>") -> ConsentHandler:
94
+ """Loopback redirect receiver (RFC 8252 §7.3): a tiny local HTTP server on a thread."""
95
+ result: dict[str, Any] = {}
96
+ got = threading.Event()
97
+
98
+ class Handler(BaseHTTPRequestHandler):
99
+ def do_GET(self) -> None: # noqa: N802
100
+ u = urlparse(self.path)
101
+ if u.path != path:
102
+ self.send_response(404)
103
+ self.end_headers()
104
+ return
105
+ q = parse_qs(u.query)
106
+ self.send_response(200)
107
+ self.send_header("content-type", "text/html; charset=utf-8")
108
+ self.end_headers()
109
+ self.wfile.write(success_html.encode())
110
+ if "code" in q:
111
+ result["code"] = q["code"][0]
112
+ result["state"] = q.get("state", [None])[0]
113
+ else:
114
+ result["error"] = f"{q.get('error', ['no code in redirect'])[0]} {q.get('error_description', [''])[0]}".strip()
115
+ got.set()
116
+
117
+ def log_message(self, *a: Any) -> None:
118
+ pass
119
+
120
+ HTTPServer.allow_reuse_address = True
121
+ server = HTTPServer((host, port), Handler)
122
+ threading.Thread(target=server.serve_forever, daemon=True).start()
123
+
124
+ def close() -> None:
125
+ server.shutdown()
126
+ server.server_close()
127
+ actual_port = server.server_address[1]
128
+
129
+ async def default_show(url: str) -> None:
130
+ print(f"Open this URL to authorize:\n{url}", file=sys.stderr)
131
+
132
+ async def wait() -> tuple[str, str | None]:
133
+ ok = await asyncio.to_thread(got.wait, timeout)
134
+ if not ok:
135
+ raise TimeoutError("timed out waiting for the authorization redirect")
136
+ if "error" in result:
137
+ raise RuntimeError(f"authorization failed: {result['error']}")
138
+ return result["code"], result.get("state")
139
+
140
+ return ConsentHandler(redirect_url=f"http://{host}:{actual_port}{path}", on_authorization_url=on_authorization_url or default_show, wait_for_code=wait, close=close)
141
+
142
+
143
+ def manual_consent(redirect_url: str, on_authorization_url: Callable[[str], Awaitable[None]], wait_for_code: Callable[[], Awaitable[tuple[str, str | None]]]) -> ConsentHandler:
144
+ """For headless agents: relay the URL and the code through whatever channel exists."""
145
+ return ConsentHandler(redirect_url=redirect_url, on_authorization_url=on_authorization_url, wait_for_code=wait_for_code)
146
+
147
+
148
+ # ---------- provider ----------
149
+
150
+
151
+ def oauth_provider(server_name: str, server_url: str, *, store: TokenStore, consent: ConsentHandler, client_name: str = "protogrid-sdk agent", scope: str | None = None, client_metadata_url: str | None = None) -> Any:
152
+ """``mcp.client.auth.OAuthClientProvider`` (an ``httpx.Auth``) backed by ``store``, scoped to one server.
153
+
154
+ Requires the ``mcp`` package. Pass the result as ``auth`` to the transports / ``open_session``.
155
+ """
156
+ from mcp.client.auth import OAuthClientProvider, TokenStorage
157
+ from mcp.shared.auth import AuthorizationCodeResult, OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
158
+
159
+ k_tokens, k_client = f"{server_name}:tokens", f"{server_name}:client"
160
+
161
+ class _Storage(TokenStorage):
162
+ async def get_tokens(self) -> OAuthToken | None:
163
+ d = store.get(k_tokens)
164
+ return OAuthToken.model_validate(d) if d else None
165
+
166
+ async def set_tokens(self, tokens: OAuthToken) -> None:
167
+ store.set(k_tokens, tokens.model_dump(mode="json", exclude_none=True))
168
+
169
+ async def get_client_info(self) -> OAuthClientInformationFull | None:
170
+ d = store.get(k_client)
171
+ return OAuthClientInformationFull.model_validate(d) if d else None
172
+
173
+ async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
174
+ store.set(k_client, client_info.model_dump(mode="json", exclude_none=True))
175
+
176
+ async def callback() -> AuthorizationCodeResult:
177
+ code, state = await consent.wait_for_code()
178
+ return AuthorizationCodeResult(code=code, state=state)
179
+
180
+ metadata = OAuthClientMetadata(
181
+ client_name=client_name,
182
+ redirect_uris=[consent.redirect_url], # type: ignore[list-item]
183
+ grant_types=["authorization_code", "refresh_token"],
184
+ response_types=["code"],
185
+ token_endpoint_auth_method="none",
186
+ **({"scope": scope} if scope else {}),
187
+ )
188
+ kwargs: dict[str, Any] = {}
189
+ if client_metadata_url:
190
+ kwargs["client_metadata_url"] = client_metadata_url
191
+ return OAuthClientProvider(server_url=server_url, client_metadata=metadata, storage=_Storage(), redirect_handler=consent.on_authorization_url, callback_handler=callback, **kwargs)
protogrid/secrets.py ADDED
@@ -0,0 +1,64 @@
1
+ """``${NAME}`` placeholder substitution. The registry never sees secret values; the caller
2
+ supplies them here, at the last moment, from whatever store it already has."""
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Mapping
7
+ from typing import Any, TypeVar
8
+
9
+ PLACEHOLDER = re.compile(r"\$\{([A-Za-z0-9_.-]+)\}")
10
+ T = TypeVar("T")
11
+
12
+
13
+ class MissingSecretsError(KeyError):
14
+ def __init__(self, missing: list[str]):
15
+ super().__init__(f"missing secrets: {', '.join(missing)}")
16
+ self.missing = missing
17
+
18
+
19
+ def placeholders_in(value: Any) -> list[str]:
20
+ """Placeholder names that appear anywhere in ``value`` (strings, lists, dicts)."""
21
+ out: dict[str, None] = {}
22
+
23
+ def visit(v: Any) -> None:
24
+ if isinstance(v, str):
25
+ for m in PLACEHOLDER.finditer(v):
26
+ out.setdefault(m.group(1))
27
+ elif isinstance(v, list):
28
+ for x in v:
29
+ visit(x)
30
+ elif isinstance(v, dict):
31
+ for x in v.values():
32
+ visit(x)
33
+
34
+ visit(value)
35
+ return list(out)
36
+
37
+
38
+ def substitute_secrets(value: T, secrets: Mapping[str, str | None], *, partial: bool = False) -> T:
39
+ """Deep copy of ``value`` with every ``${NAME}`` replaced from ``secrets``.
40
+
41
+ Raises :class:`MissingSecretsError` when a placeholder has no value, unless ``partial``.
42
+ """
43
+ missing: dict[str, None] = {}
44
+
45
+ def repl(m: re.Match[str]) -> str:
46
+ v = secrets.get(m.group(1))
47
+ if v is None:
48
+ missing.setdefault(m.group(1))
49
+ return m.group(0)
50
+ return v
51
+
52
+ def visit(v: Any) -> Any:
53
+ if isinstance(v, str):
54
+ return PLACEHOLDER.sub(repl, v)
55
+ if isinstance(v, list):
56
+ return [visit(x) for x in v]
57
+ if isinstance(v, dict):
58
+ return {k: visit(x) for k, x in v.items()}
59
+ return v
60
+
61
+ out = visit(value)
62
+ if missing and not partial:
63
+ raise MissingSecretsError(list(missing))
64
+ return out
protogrid/types.py ADDED
@@ -0,0 +1,111 @@
1
+ """Wire types of the protogrid REST API (TypedDicts: zero-copy over the JSON).
2
+
3
+ Hand-written to match the platform's descriptor contract; the SDK carries no server-specific
4
+ knowledge (design rule: SDKs are server-agnostic).
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from typing import Any, Literal, TypedDict
9
+
10
+ ConnectionClass = Literal["R0", "R1", "R2", "L0", "unknown"]
11
+ AuthType = Literal["none", "api_key", "oauth2", "unknown"]
12
+ ConnectionTarget = Literal["mcpServers", "vscode", "cursor", "claude-code-cli", "codex-toml", "opencode", "gemini", "goose"]
13
+ TrustFlag = Literal["multi-version-spam", "duplicate-repo", "no-repository", "no-connection", "deprecated", "unreachable", "blocked", "deleted"]
14
+
15
+ #: Reverse-DNS namespace of protogrid.dev under ``_meta``.
16
+ META_NS = "dev.protogrid"
17
+
18
+
19
+ class NextAction(TypedDict, total=False):
20
+ action: str
21
+ description: str
22
+ href: str
23
+ arguments: dict[str, Any]
24
+
25
+
26
+ class MatchedTool(TypedDict):
27
+ name: str
28
+ description: str
29
+
30
+
31
+ class SearchResult(TypedDict):
32
+ name: str
33
+ title: str | None
34
+ description: str
35
+ connection_class: ConnectionClass
36
+ autonomous: bool
37
+ human_steps: list[str]
38
+ categories: list[str]
39
+ trust_score: int | None
40
+ trust_flags: list[str]
41
+ tool_count: int
42
+ score: float
43
+ matched_tools: list[MatchedTool]
44
+
45
+
46
+ class SearchResponse(TypedDict):
47
+ query: str
48
+ mode: str
49
+ count: int
50
+ results: list[SearchResult]
51
+ next_actions: list[NextAction]
52
+
53
+
54
+ class SecretRef(TypedDict):
55
+ name: str
56
+ where: str
57
+
58
+
59
+ class RemoteEntry(TypedDict, total=False):
60
+ type: Literal["http", "sse"]
61
+ url: str
62
+ headers: dict[str, str]
63
+
64
+
65
+ class PackageEntry(TypedDict, total=False):
66
+ command: str
67
+ args: list[str]
68
+ env: dict[str, str]
69
+
70
+
71
+ McpServersEntry = RemoteEntry | PackageEntry
72
+
73
+
74
+ class ConnectionResponse(TypedDict, total=False):
75
+ server: str
76
+ key: str
77
+ kind: Literal["remote", "package", "bundle"]
78
+ class_: ConnectionClass # JSON key is "class"; use conn["class"]
79
+ autonomous: bool
80
+ human_steps: list[str]
81
+ target: ConnectionTarget
82
+ content_type: str
83
+ connection: Any
84
+ secrets: list[SecretRef]
85
+ auth_type: AuthType
86
+ oauth: dict[str, Any]
87
+ placeholders: str
88
+ next_actions: list[NextAction]
89
+
90
+
91
+ class ToolEntry(TypedDict, total=False):
92
+ name: str
93
+ title: str | None
94
+ description: str
95
+ input_schema: Any
96
+ output_schema: Any
97
+ annotations: Any
98
+ source: str
99
+ observed_at: str
100
+
101
+
102
+ class ListToolsResponse(TypedDict):
103
+ server: str
104
+ count: int
105
+ tools: list[ToolEntry]
106
+ next_cursor: str | None
107
+ next_actions: list[NextAction]
108
+
109
+
110
+ Descriptor = dict[str, Any]
111
+ """``{"server": <official server.json>, "_meta": {...}, "next_actions": [...]}``."""
@@ -0,0 +1,41 @@
1
+ Metadata-Version: 2.5
2
+ Name: protogrid-sdk
3
+ Version: 0.2.0
4
+ Summary: Client for the protogrid MCP registry: find servers by intent, get a connection block, connect with no human in the loop.
5
+ Project-URL: Homepage, https://protogrid.dev
6
+ Project-URL: Documentation, https://docs.protogrid.dev/sdk/python/
7
+ Project-URL: Repository, https://github.com/protogrid-dev/sdk
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Requires-Python: >=3.10
11
+ Requires-Dist: httpx>=0.27
12
+ Provides-Extra: mcp
13
+ Requires-Dist: mcp>=1.20; extra == 'mcp'
14
+ Provides-Extra: pydantic-ai
15
+ Requires-Dist: pydantic-ai>=1.0; extra == 'pydantic-ai'
16
+ Description-Content-Type: text/markdown
17
+
18
+ # protogrid (Python)
19
+
20
+ Client for the protogrid registry: find MCP servers by intent, get a machine-readable connection
21
+ block, and connect with no human in the loop wherever the server allows it.
22
+
23
+ ```python
24
+ import os
25
+ from protogrid import ProtogridClient, find_connectable, open_session
26
+
27
+ registry = ProtogridClient() # public registry; ProtogridClient("http://localhost:8080") for a local stack
28
+ found = find_connectable(registry, "send an email", secrets=os.environ)
29
+ async with open_session(found.connection, os.environ) as session: # mcp.ClientSession, initialized
30
+ tools = await session.list_tools()
31
+ ```
32
+
33
+ - `ProtogridClient` / `AsyncProtogridClient`: `search`, `get_server`, `list_tools`, `list_all_tools`, `get_connection`.
34
+ - No key needed. A free key from https://protogrid.dev/account raises the limits (60 requests per minute, 5,000 per day); the client reads `PROTOGRID_API_KEY`, or pass `ProtogridClient(api_key=...)`.
35
+ - Connection blocks carry `${NAME}` placeholders; `substitute_secrets` fills them from your own store. The registry never sees secret values.
36
+ - `connection_class`: **R0** remote, no auth · **R1** remote, static secret you hold · **R2** remote OAuth (one consent) · **L0** local package (`allow_local=True`) · `unknown`.
37
+ - R2: `open_session(conn, oauth=OAuthOptions(store, consent))` runs the one-time consent (`loopback_consent` or `manual_consent`) through the official SDK's OAuth provider and keeps tokens in your `TokenStore`; later runs need no human.
38
+ - PydanticAI: `to_pydantic_ai(conn, secrets)` returns an `MCPToolset`. `to_fastmcp_transport` and `to_mcp_servers` are pure formatters.
39
+ - Install: `pip install protogrid-sdk` (imported as `protogrid`). Extras: `protogrid-sdk[mcp]` for `open_session`, `protogrid-sdk[pydantic-ai]` for the toolset.
40
+
41
+ Examples: `examples/find_and_call.py` (search → connect → call a tool) and `examples/pydantic_ai_agent.py` (agent with a registry-found toolset, no LLM key needed).
@@ -0,0 +1,11 @@
1
+ protogrid/__init__.py,sha256=X2LfJmywrXu7PVA5hu54TS4f3J2ryZ5KVs7KPclPJMg,1465
2
+ protogrid/client.py,sha256=u6O3vwvK3zSCvFylyOtpxwaCFQHGmPtQkqrzbx5hS4E,7501
3
+ protogrid/connect.py,sha256=ydBW2kwVendBIqPVQB1GyQ0mgNRoPe9WkDjys-JY4mU,7110
4
+ protogrid/formatters.py,sha256=wHFMZWZbJVWMgFxSNNX1DGulLuP_-f44sIfSv8LTW8c,2160
5
+ protogrid/oauth.py,sha256=XAsP2v0Fx9ro_tVG3mNKcUYaZWZUarinRKtEIfpHv6M,7697
6
+ protogrid/secrets.py,sha256=Ii8fSHK9vYNzU0we4Jso17UXGLuLeUyismEd53OARBs,1976
7
+ protogrid/types.py,sha256=RGZRkd99PpRUgL9eilUbPKJpQ3Y36kfM9CWpEUP9pew,2717
8
+ protogrid_sdk-0.2.0.dist-info/METADATA,sha256=gfuewy3uFWriMW3UODMqV6_buIpoenOifPoWO4ZycYU,2605
9
+ protogrid_sdk-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
10
+ protogrid_sdk-0.2.0.dist-info/licenses/LICENSE,sha256=gSy1bL-4EmcZaPwVKALrMPKBTJz_sQxqssfgF6Fbhg8,1079
11
+ protogrid_sdk-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 protogrid contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.