protogrid-sdk 0.2.0__tar.gz
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_sdk-0.2.0/.gitignore +7 -0
- protogrid_sdk-0.2.0/LICENSE +21 -0
- protogrid_sdk-0.2.0/PKG-INFO +41 -0
- protogrid_sdk-0.2.0/README.md +24 -0
- protogrid_sdk-0.2.0/examples/find_and_call.py +66 -0
- protogrid_sdk-0.2.0/examples/pydantic_ai_agent.py +43 -0
- protogrid_sdk-0.2.0/pyproject.toml +34 -0
- protogrid_sdk-0.2.0/src/protogrid/__init__.py +17 -0
- protogrid_sdk-0.2.0/src/protogrid/client.py +176 -0
- protogrid_sdk-0.2.0/src/protogrid/connect.py +146 -0
- protogrid_sdk-0.2.0/src/protogrid/formatters.py +42 -0
- protogrid_sdk-0.2.0/src/protogrid/oauth.py +191 -0
- protogrid_sdk-0.2.0/src/protogrid/secrets.py +64 -0
- protogrid_sdk-0.2.0/src/protogrid/types.py +111 -0
- protogrid_sdk-0.2.0/tests/test_core.py +122 -0
- protogrid_sdk-0.2.0/tests/test_oauth.py +164 -0
- protogrid_sdk-0.2.0/uv.lock +2819 -0
|
@@ -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.
|
|
@@ -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,24 @@
|
|
|
1
|
+
# protogrid (Python)
|
|
2
|
+
|
|
3
|
+
Client for the protogrid registry: find MCP servers by intent, get a machine-readable connection
|
|
4
|
+
block, and connect with no human in the loop wherever the server allows it.
|
|
5
|
+
|
|
6
|
+
```python
|
|
7
|
+
import os
|
|
8
|
+
from protogrid import ProtogridClient, find_connectable, open_session
|
|
9
|
+
|
|
10
|
+
registry = ProtogridClient() # public registry; ProtogridClient("http://localhost:8080") for a local stack
|
|
11
|
+
found = find_connectable(registry, "send an email", secrets=os.environ)
|
|
12
|
+
async with open_session(found.connection, os.environ) as session: # mcp.ClientSession, initialized
|
|
13
|
+
tools = await session.list_tools()
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
- `ProtogridClient` / `AsyncProtogridClient`: `search`, `get_server`, `list_tools`, `list_all_tools`, `get_connection`.
|
|
17
|
+
- 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=...)`.
|
|
18
|
+
- Connection blocks carry `${NAME}` placeholders; `substitute_secrets` fills them from your own store. The registry never sees secret values.
|
|
19
|
+
- `connection_class`: **R0** remote, no auth · **R1** remote, static secret you hold · **R2** remote OAuth (one consent) · **L0** local package (`allow_local=True`) · `unknown`.
|
|
20
|
+
- 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.
|
|
21
|
+
- PydanticAI: `to_pydantic_ai(conn, secrets)` returns an `MCPToolset`. `to_fastmcp_transport` and `to_mcp_servers` are pure formatters.
|
|
22
|
+
- Install: `pip install protogrid-sdk` (imported as `protogrid`). Extras: `protogrid-sdk[mcp]` for `open_session`, `protogrid-sdk[pydantic-ai]` for the toolset.
|
|
23
|
+
|
|
24
|
+
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,66 @@
|
|
|
1
|
+
"""End to end, no human: find a server for an intent, connect, call a tool you did not know existed.
|
|
2
|
+
|
|
3
|
+
uv run python examples/find_and_call.py "get the current weather for a city" Madrid
|
|
4
|
+
|
|
5
|
+
Uses the public registry; set REGISTRY_URL=http://localhost:8080 for a local stack.
|
|
6
|
+
Secrets for R1 servers come from the environment: any ${NAME} placeholder is read from os.environ[NAME].
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
from protogrid import ProtogridClient, find_connectable, open_session
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def args_for(schema: dict, string_value: str) -> dict | None:
|
|
18
|
+
out: dict = {}
|
|
19
|
+
props = schema.get("properties") or {}
|
|
20
|
+
for r in schema.get("required") or []:
|
|
21
|
+
p = props.get(r) or {}
|
|
22
|
+
if "default" in p:
|
|
23
|
+
out[r] = p["default"]
|
|
24
|
+
elif p.get("enum"):
|
|
25
|
+
out[r] = p["enum"][0]
|
|
26
|
+
elif p.get("type") == "string":
|
|
27
|
+
out[r] = string_value
|
|
28
|
+
elif p.get("type") in ("number", "integer"):
|
|
29
|
+
out[r] = 1
|
|
30
|
+
elif p.get("type") == "boolean":
|
|
31
|
+
out[r] = True
|
|
32
|
+
else:
|
|
33
|
+
return None
|
|
34
|
+
return out
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def main() -> int:
|
|
38
|
+
intent = sys.argv[1] if len(sys.argv) > 1 else "get the current weather for a city"
|
|
39
|
+
string_value = sys.argv[2] if len(sys.argv) > 2 else intent
|
|
40
|
+
registry = ProtogridClient(**({"base_url": os.environ["REGISTRY_URL"]} if os.environ.get("REGISTRY_URL") else {}))
|
|
41
|
+
found = find_connectable(registry, intent, secrets=os.environ)
|
|
42
|
+
if not found:
|
|
43
|
+
print(f'nothing autonomous found for "{intent}"')
|
|
44
|
+
return 1
|
|
45
|
+
r = found.result
|
|
46
|
+
print(f"→ {r['name']} ({r['connection_class']}, trust {r['trust_score']}); matched: {', '.join(t['name'] for t in r['matched_tools']) or 'server text'}")
|
|
47
|
+
async with open_session(found.connection, os.environ) as session:
|
|
48
|
+
tools = (await session.list_tools()).tools
|
|
49
|
+
print(f"connected; {len(tools)} tools: {', '.join(t.name for t in tools[:8])}{', …' if len(tools) > 8 else ''}")
|
|
50
|
+
matched = [t for m in r["matched_tools"] for t in tools if t.name == m["name"]]
|
|
51
|
+
for t in [*matched, *tools]:
|
|
52
|
+
a = args_for(getattr(t, "input_schema", None) or getattr(t, "inputSchema", None) or {}, string_value)
|
|
53
|
+
if a is None:
|
|
54
|
+
continue
|
|
55
|
+
print(f"calling {t.name} with {a}")
|
|
56
|
+
out = await session.call_tool(t.name, a)
|
|
57
|
+
text = "\n".join(getattr(c, "text", "") for c in out.content)
|
|
58
|
+
print("tool error:" if getattr(out, "is_error", getattr(out, "isError", False)) else "result:", text[:600])
|
|
59
|
+
break
|
|
60
|
+
else:
|
|
61
|
+
print("no tool callable without more information; stopping here")
|
|
62
|
+
return 0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
if __name__ == "__main__":
|
|
66
|
+
raise SystemExit(asyncio.run(main()))
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""PydanticAI flagship: a registry-found MCP server becomes a toolset of an agent.
|
|
2
|
+
|
|
3
|
+
uv run python examples/pydantic_ai_agent.py "get the current weather for a city"
|
|
4
|
+
|
|
5
|
+
Uses the public registry; set REGISTRY_URL=http://localhost:8080 for a local stack.
|
|
6
|
+
Uses PydanticAI's TestModel so it runs without an LLM key; swap the model for a real one to let the
|
|
7
|
+
model choose and call the tools. R1 secrets come from the environment.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
from pydantic_ai import Agent
|
|
16
|
+
from pydantic_ai.messages import ToolCallPart
|
|
17
|
+
from pydantic_ai.models.test import TestModel
|
|
18
|
+
|
|
19
|
+
from protogrid import ProtogridClient, find_connectable, to_pydantic_ai
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def main() -> int:
|
|
23
|
+
intent = sys.argv[1] if len(sys.argv) > 1 else "get the current weather for a city"
|
|
24
|
+
registry = ProtogridClient(**({"base_url": os.environ["REGISTRY_URL"]} if os.environ.get("REGISTRY_URL") else {}))
|
|
25
|
+
found = find_connectable(registry, intent, secrets=os.environ)
|
|
26
|
+
if not found:
|
|
27
|
+
print(f'nothing autonomous found for "{intent}"')
|
|
28
|
+
return 1
|
|
29
|
+
print(f"→ {found.result['name']} ({found.result['connection_class']}, trust {found.result['trust_score']})")
|
|
30
|
+
toolset = to_pydantic_ai(found.connection, os.environ)
|
|
31
|
+
# TestModel calls every available tool once with schema-derived arguments, so the run shows
|
|
32
|
+
# the agent using tools it learned about from the registry, with no LLM key.
|
|
33
|
+
agent = Agent(TestModel(), toolsets=[toolset])
|
|
34
|
+
async with agent:
|
|
35
|
+
result = await agent.run(intent)
|
|
36
|
+
calls = [p for m in result.all_messages() for p in m.parts if isinstance(p, ToolCallPart)]
|
|
37
|
+
print(f"agent called {len(calls)} registry-found tools: {', '.join(c.tool_name for c in calls)}")
|
|
38
|
+
print("run finished:", str(result.output)[:160])
|
|
39
|
+
return 0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
if __name__ == "__main__":
|
|
43
|
+
raise SystemExit(asyncio.run(main()))
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "protogrid-sdk"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "Client for the protogrid MCP registry: find servers by intent, get a connection block, connect with no human in the loop."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.10"
|
|
8
|
+
dependencies = ["httpx>=0.27"]
|
|
9
|
+
|
|
10
|
+
[project.optional-dependencies]
|
|
11
|
+
mcp = ["mcp>=1.20"]
|
|
12
|
+
pydantic-ai = ["pydantic-ai>=1.0"]
|
|
13
|
+
|
|
14
|
+
[dependency-groups]
|
|
15
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.24", "mcp>=1.20", "pydantic-ai>=1.0", "uvicorn>=0.30", "starlette>=0.40"]
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
Homepage = "https://protogrid.dev"
|
|
19
|
+
Documentation = "https://docs.protogrid.dev/sdk/python/"
|
|
20
|
+
Repository = "https://github.com/protogrid-dev/sdk"
|
|
21
|
+
|
|
22
|
+
[build-system]
|
|
23
|
+
requires = ["hatchling"]
|
|
24
|
+
build-backend = "hatchling.build"
|
|
25
|
+
|
|
26
|
+
[tool.hatch.build.targets.wheel]
|
|
27
|
+
packages = ["src/protogrid"]
|
|
28
|
+
|
|
29
|
+
[tool.pytest.ini_options]
|
|
30
|
+
asyncio_mode = "auto"
|
|
31
|
+
testpaths = ["tests"]
|
|
32
|
+
|
|
33
|
+
[tool.uv]
|
|
34
|
+
package = true
|
|
@@ -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"
|
|
@@ -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})
|
|
@@ -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)
|