pi-python-core 0.8.2__py3-none-any.whl → 0.9.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.
pi_python/_version.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.8.2"
1
+ __version__ = "0.9.0"
pi_python/mcp.py CHANGED
@@ -1,7 +1,8 @@
1
1
  """Use the tools of an MCP server as Agent tools.
2
2
 
3
3
  `mcp_tools(session)` wraps every tool of a connected session from the official `mcp` SDK
4
- (1.x or 2.x); `connect_stdio(...)` also starts a stdio server and closes it afterwards.
4
+ (1.x or 2.x); `connect_stdio(...)` also starts a stdio server and closes it afterwards, and
5
+ `connect_http(...)` connects to a streamable HTTP server.
5
6
  The conversion follows Pi's MCP adapter: text and images pass through, embedded text and
6
7
  image resources are unwrapped, other blocks become short text placeholders, and MCP's
7
8
  `isError` marks a failed call. Install with ``pip install 'pi-python-core[mcp]'``.
@@ -16,7 +17,7 @@ import json
16
17
  import re
17
18
  import warnings
18
19
  from collections.abc import AsyncIterator, Iterable, Mapping, Sequence
19
- from contextlib import asynccontextmanager
20
+ from contextlib import AsyncExitStack, asynccontextmanager
20
21
  from typing import Any
21
22
 
22
23
  from .errors import ConfigurationError
@@ -177,11 +178,93 @@ async def connect_stdio(
177
178
  tools = await mcp_tools(session, prefix=prefix, names=names)
178
179
  yield tools
179
180
  except BaseExceptionGroup as group:
180
- # The SDK's task groups wrap a single failure ("Connection closed", a timeout,
181
- # or an error from the caller's block), sometimes twice; raise that failure itself.
182
- error: BaseException = group
183
- while isinstance(error, BaseExceptionGroup) and len(error.exceptions) == 1:
184
- error = error.exceptions[0]
185
- if error is group:
186
- raise
187
- raise error from None
181
+ _raise_single(group)
182
+
183
+
184
+ @asynccontextmanager
185
+ async def connect_http(
186
+ url: str,
187
+ *,
188
+ headers: Mapping[str, str] | None = None,
189
+ prefix: str | None = None,
190
+ names: Iterable[str] | None = None,
191
+ init_timeout: float = 30.0,
192
+ ) -> AsyncIterator[list[Tool]]:
193
+ """Connect to a streamable HTTP MCP server, yield its tools, and disconnect afterwards.
194
+
195
+ ``async with connect_http("https://example.org/mcp", headers={...}) as tools: ...``
196
+
197
+ The legacy SSE transport is not supported, as in Pi.
198
+ """
199
+ try:
200
+ from mcp import ClientSession # type: ignore[import-not-found,unused-ignore]
201
+ from mcp.client import streamable_http as transport # type: ignore[import-not-found,unused-ignore]
202
+ except ImportError as exc:
203
+ raise ImportError(
204
+ "connect_http needs the MCP SDK: pip install 'pi-python-core[mcp]'"
205
+ ) from exc
206
+ from mcp.shared._httpx_utils import create_mcp_http_client # type: ignore[import-not-found,unused-ignore]
207
+
208
+ origin = _origin(url)
209
+
210
+ async def same_origin(request: Any) -> None:
211
+ # Some SDK 1.x versions follow redirects to any origin, which would send the
212
+ # headers (often an API key) there. Redirects within the server's origin, such
213
+ # as /mcp to /mcp/, still work. SDK 2.x applies the same rule itself.
214
+ target = _origin(str(request.url))
215
+ if target != origin and target != ("https", *origin[1:]):
216
+ raise ConnectionError(
217
+ f"MCP server at {origin[1]} redirected to another origin ({target[1]});"
218
+ " not following"
219
+ )
220
+
221
+ def client_without_redirects(*args: Any, **kwargs: Any) -> Any:
222
+ client = create_mcp_http_client(*args, **kwargs)
223
+ hooks = dict(client.event_hooks)
224
+ hooks["request"] = [*hooks.get("request", []), same_origin]
225
+ client.event_hooks = hooks
226
+ return client
227
+
228
+ try:
229
+ async with AsyncExitStack() as stack:
230
+ if hasattr(transport, "streamable_http_client"): # SDK 2.x and late 1.x
231
+ client = await stack.enter_async_context(
232
+ client_without_redirects(headers=dict(headers or {}))
233
+ )
234
+ streams = await stack.enter_async_context(
235
+ transport.streamable_http_client(url, http_client=client)
236
+ )
237
+ else:
238
+ streams = await stack.enter_async_context(
239
+ transport.streamablehttp_client( # type: ignore[attr-defined,unused-ignore]
240
+ url,
241
+ headers=dict(headers or {}),
242
+ httpx_client_factory=client_without_redirects,
243
+ )
244
+ )
245
+ session = await stack.enter_async_context(ClientSession(streams[0], streams[1]))
246
+ async with asyncio.timeout(init_timeout):
247
+ await session.initialize()
248
+ tools = await mcp_tools(session, prefix=prefix, names=names)
249
+ yield tools
250
+ except BaseExceptionGroup as group:
251
+ _raise_single(group)
252
+
253
+
254
+ def _origin(url: str) -> tuple[str, str, int | None]:
255
+ from urllib.parse import urlsplit
256
+
257
+ parts = urlsplit(url)
258
+ default = {"http": 80, "https": 443}.get(parts.scheme)
259
+ return parts.scheme, parts.hostname or "", parts.port or default
260
+
261
+
262
+ def _raise_single(group: BaseExceptionGroup) -> None:
263
+ # The SDK's task groups wrap a single failure ("Connection closed", a timeout, or an
264
+ # error from the caller's block), sometimes twice; raise that failure itself.
265
+ error: BaseException = group
266
+ while isinstance(error, BaseExceptionGroup) and len(error.exceptions) == 1:
267
+ error = error.exceptions[0]
268
+ if error is group:
269
+ raise group
270
+ raise error from None