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 +17 -0
- protogrid/client.py +176 -0
- protogrid/connect.py +146 -0
- protogrid/formatters.py +42 -0
- protogrid/oauth.py +191 -0
- protogrid/secrets.py +64 -0
- protogrid/types.py +111 -0
- protogrid_sdk-0.2.0.dist-info/METADATA +41 -0
- protogrid_sdk-0.2.0.dist-info/RECORD +11 -0
- protogrid_sdk-0.2.0.dist-info/WHEEL +4 -0
- protogrid_sdk-0.2.0.dist-info/licenses/LICENSE +21 -0
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
|
protogrid/formatters.py
ADDED
|
@@ -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,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.
|