chuk-tool-processor 0.1.2__py3-none-any.whl → 0.1.4__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.

Potentially problematic release.


This version of chuk-tool-processor might be problematic. Click here for more details.

@@ -0,0 +1,103 @@
1
+ # chuk_tool_processor/mcp/transport/base_transport.py
2
+ """
3
+ Abstract transport layer for MCP communication.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ from abc import ABC, abstractmethod
8
+ from typing import Any, Dict, List
9
+
10
+
11
+ class MCPBaseTransport(ABC):
12
+ """
13
+ Abstract base class for MCP transport mechanisms.
14
+ """
15
+
16
+ # ------------------------------------------------------------------ #
17
+ # connection lifecycle #
18
+ # ------------------------------------------------------------------ #
19
+ @abstractmethod
20
+ async def initialize(self) -> bool:
21
+ """
22
+ Establish the connection.
23
+
24
+ Returns
25
+ -------
26
+ bool
27
+ ``True`` if the connection was initialised successfully.
28
+ """
29
+ raise NotImplementedError
30
+
31
+ @abstractmethod
32
+ async def close(self) -> None:
33
+ """Tear down the connection and release all resources."""
34
+ raise NotImplementedError
35
+
36
+ # ------------------------------------------------------------------ #
37
+ # diagnostics #
38
+ # ------------------------------------------------------------------ #
39
+ @abstractmethod
40
+ async def send_ping(self) -> bool:
41
+ """
42
+ Send a **ping** request.
43
+
44
+ Returns
45
+ -------
46
+ bool
47
+ ``True`` on success, ``False`` otherwise.
48
+ """
49
+ raise NotImplementedError
50
+
51
+ # ------------------------------------------------------------------ #
52
+ # tool handling #
53
+ # ------------------------------------------------------------------ #
54
+ @abstractmethod
55
+ async def get_tools(self) -> List[Dict[str, Any]]:
56
+ """
57
+ Return a list with *all* tool definitions exposed by the server.
58
+ """
59
+ raise NotImplementedError
60
+
61
+ @abstractmethod
62
+ async def call_tool(
63
+ self, tool_name: str, arguments: Dict[str, Any]
64
+ ) -> Dict[str, Any]:
65
+ """
66
+ Execute *tool_name* with *arguments* and return the normalised result.
67
+ """
68
+ raise NotImplementedError
69
+
70
+ # ------------------------------------------------------------------ #
71
+ # new: resources & prompts #
72
+ # ------------------------------------------------------------------ #
73
+ @abstractmethod
74
+ async def list_resources(self) -> Dict[str, Any]:
75
+ """
76
+ Retrieve the server’s resources catalogue.
77
+
78
+ Expected shape::
79
+ { "resources": [ {...}, ... ], "nextCursor": "…", … }
80
+ """
81
+ raise NotImplementedError
82
+
83
+ @abstractmethod
84
+ async def list_prompts(self) -> Dict[str, Any]:
85
+ """
86
+ Retrieve the server’s prompt catalogue.
87
+
88
+ Expected shape::
89
+ { "prompts": [ {...}, ... ], "nextCursor": "…", … }
90
+ """
91
+ raise NotImplementedError
92
+
93
+ # ------------------------------------------------------------------ #
94
+ # optional helper (non-abstract) #
95
+ # ------------------------------------------------------------------ #
96
+ def get_streams(self):
97
+ """
98
+ Return a list of ``(read_stream, write_stream)`` tuples.
99
+
100
+ Transports that do not expose their low-level streams can simply leave
101
+ the default implementation (which returns an empty list).
102
+ """
103
+ return []
@@ -0,0 +1,189 @@
1
+ # chuk_tool_processor/mcp/transport/sse_transport.py
2
+ """
3
+ Server-Sent Events (SSE) transport for MCP communication – implemented with **httpx**.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ import contextlib
9
+ import json
10
+ from typing import Any, Dict, List, Optional
11
+
12
+ import httpx
13
+
14
+ from .base_transport import MCPBaseTransport
15
+
16
+ # --------------------------------------------------------------------------- #
17
+ # Helpers #
18
+ # --------------------------------------------------------------------------- #
19
+ DEFAULT_TIMEOUT = 5.0 # seconds
20
+ HEADERS_JSON: Dict[str, str] = {"accept": "application/json"}
21
+
22
+
23
+ def _url(base: str, path: str) -> str:
24
+ """Join *base* and *path* with exactly one slash."""
25
+ return f"{base.rstrip('/')}/{path.lstrip('/')}"
26
+
27
+
28
+ # --------------------------------------------------------------------------- #
29
+ # Transport #
30
+ # --------------------------------------------------------------------------- #
31
+ class SSETransport(MCPBaseTransport):
32
+ """
33
+ Minimal SSE/REST transport. It speaks a simple REST dialect:
34
+
35
+ GET /ping → 200 OK
36
+ GET /tools/list → {"tools": [...]}
37
+ POST /tools/call → {"name": ..., "result": ...}
38
+ GET /resources/list → {"resources": [...]}
39
+ GET /prompts/list → {"prompts": [...]}
40
+ GET /events → <text/event-stream>
41
+ """
42
+
43
+ EVENTS_PATH = "/events"
44
+
45
+ # ------------------------------------------------------------------ #
46
+ # Construction #
47
+ # ------------------------------------------------------------------ #
48
+ def __init__(self, url: str, api_key: Optional[str] = None) -> None:
49
+ self.base_url = url.rstrip("/")
50
+ self.api_key = api_key
51
+
52
+ # httpx client (None until initialise)
53
+ self._client: httpx.AsyncClient | None = None
54
+ self.session: httpx.AsyncClient | None = None # ← kept for legacy tests
55
+
56
+ # background reader
57
+ self._reader_task: asyncio.Task | None = None
58
+ self._incoming_queue: "asyncio.Queue[dict[str, Any]]" = asyncio.Queue()
59
+
60
+ # ------------------------------------------------------------------ #
61
+ # Life-cycle #
62
+ # ------------------------------------------------------------------ #
63
+ async def initialize(self) -> bool:
64
+ """Open the httpx client and start the /events consumer."""
65
+ if self._client: # already initialised
66
+ return True
67
+
68
+ self._client = httpx.AsyncClient(
69
+ headers={"authorization": self.api_key} if self.api_key else None,
70
+ timeout=DEFAULT_TIMEOUT,
71
+ )
72
+ self.session = self._client # legacy attribute for tests
73
+
74
+ # spawn reader (best-effort reconnect)
75
+ self._reader_task = asyncio.create_task(self._consume_events(), name="sse-reader")
76
+
77
+ # verify connection
78
+ return await self.send_ping()
79
+
80
+ async def close(self) -> None:
81
+ """Stop background reader and close the httpx client."""
82
+ if self._reader_task:
83
+ self._reader_task.cancel()
84
+ with contextlib.suppress(asyncio.CancelledError):
85
+ await self._reader_task
86
+ self._reader_task = None
87
+
88
+ if self._client:
89
+ await self._client.aclose()
90
+ self._client = None
91
+ self.session = None # keep tests happy
92
+
93
+ # ------------------------------------------------------------------ #
94
+ # Internal helpers #
95
+ # ------------------------------------------------------------------ #
96
+ async def _get_json(self, path: str) -> Any:
97
+ if not self._client:
98
+ raise RuntimeError("Transport not initialised")
99
+
100
+ resp = await self._client.get(_url(self.base_url, path), headers=HEADERS_JSON)
101
+ resp.raise_for_status()
102
+ return resp.json()
103
+
104
+ async def _post_json(self, path: str, payload: Dict[str, Any]) -> Any:
105
+ if not self._client:
106
+ raise RuntimeError("Transport not initialised")
107
+
108
+ resp = await self._client.post(
109
+ _url(self.base_url, path), json=payload, headers=HEADERS_JSON
110
+ )
111
+ resp.raise_for_status()
112
+ return resp.json()
113
+
114
+ # ------------------------------------------------------------------ #
115
+ # Public API (implements MCPBaseTransport) #
116
+ # ------------------------------------------------------------------ #
117
+ async def send_ping(self) -> bool:
118
+ if not self._client:
119
+ return False
120
+ try:
121
+ await self._get_json("/ping")
122
+ return True
123
+ except Exception: # pragma: no cover
124
+ return False
125
+
126
+ async def get_tools(self) -> List[Dict[str, Any]]:
127
+ if not self._client:
128
+ return []
129
+ try:
130
+ data = await self._get_json("/tools/list")
131
+ return data.get("tools", []) if isinstance(data, dict) else []
132
+ except Exception: # pragma: no cover
133
+ return []
134
+
135
+ async def call_tool(self, tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
136
+ # ─── tests expect this specific message if *not* initialised ───
137
+ if not self._client:
138
+ return {"isError": True, "error": "SSE transport not implemented"}
139
+
140
+ try:
141
+ payload = {"name": tool_name, "arguments": arguments}
142
+ return await self._post_json("/tools/call", payload)
143
+ except Exception as exc: # pragma: no cover
144
+ return {"isError": True, "error": str(exc)}
145
+
146
+ # ----------------------- extras used by StreamManager ------------- #
147
+ async def list_resources(self) -> List[Dict[str, Any]]:
148
+ if not self._client:
149
+ return []
150
+ try:
151
+ data = await self._get_json("/resources/list")
152
+ return data.get("resources", []) if isinstance(data, dict) else []
153
+ except Exception: # pragma: no cover
154
+ return []
155
+
156
+ async def list_prompts(self) -> List[Dict[str, Any]]:
157
+ if not self._client:
158
+ return []
159
+ try:
160
+ data = await self._get_json("/prompts/list")
161
+ return data.get("prompts", []) if isinstance(data, dict) else []
162
+ except Exception: # pragma: no cover
163
+ return []
164
+
165
+ # ------------------------------------------------------------------ #
166
+ # Background event-stream reader #
167
+ # ------------------------------------------------------------------ #
168
+ async def _consume_events(self) -> None: # pragma: no cover
169
+ """Continuously read `/events` and push JSON objects onto a queue."""
170
+ if not self._client:
171
+ return
172
+
173
+ while True:
174
+ try:
175
+ async with self._client.stream(
176
+ "GET", _url(self.base_url, self.EVENTS_PATH), headers=HEADERS_JSON
177
+ ) as resp:
178
+ resp.raise_for_status()
179
+ async for line in resp.aiter_lines():
180
+ if not line:
181
+ continue
182
+ try:
183
+ await self._incoming_queue.put(json.loads(line))
184
+ except json.JSONDecodeError:
185
+ continue
186
+ except asyncio.CancelledError:
187
+ break
188
+ except Exception:
189
+ await asyncio.sleep(1.0) # back-off and retry
@@ -0,0 +1,197 @@
1
+ # chuk_tool_processor/mcp/transport/stdio_transport.py
2
+ from __future__ import annotations
3
+
4
+ from contextlib import AsyncExitStack
5
+ import json
6
+ from typing import Dict, Any, List, Optional
7
+
8
+ # ------------------------------------------------------------------ #
9
+ # Local import #
10
+ # ------------------------------------------------------------------ #
11
+ from .base_transport import MCPBaseTransport
12
+
13
+ # ------------------------------------------------------------------ #
14
+ # chuk-protocol imports #
15
+ # ------------------------------------------------------------------ #
16
+ from chuk_mcp.mcp_client.transport.stdio.stdio_client import stdio_client
17
+ from chuk_mcp.mcp_client.messages.initialize.send_messages import send_initialize
18
+ from chuk_mcp.mcp_client.messages.ping.send_messages import send_ping
19
+
20
+ # tools
21
+ from chuk_mcp.mcp_client.messages.tools.send_messages import (
22
+ send_tools_call,
23
+ send_tools_list,
24
+ )
25
+
26
+ # NEW: resources & prompts
27
+ from chuk_mcp.mcp_client.messages.resources.send_messages import (
28
+ send_resources_list,
29
+ )
30
+ from chuk_mcp.mcp_client.messages.prompts.send_messages import (
31
+ send_prompts_list,
32
+ )
33
+
34
+
35
+ class StdioTransport(MCPBaseTransport):
36
+ """
37
+ Stdio transport for MCP communication.
38
+ """
39
+
40
+ def __init__(self, server_params):
41
+ self.server_params = server_params
42
+ self.read_stream = None
43
+ self.write_stream = None
44
+ self._context_stack: Optional[AsyncExitStack] = None
45
+
46
+ # --------------------------------------------------------------------- #
47
+ # Connection management #
48
+ # --------------------------------------------------------------------- #
49
+ async def initialize(self) -> bool:
50
+ try:
51
+ self._context_stack = AsyncExitStack()
52
+ await self._context_stack.__aenter__()
53
+
54
+ ctx = stdio_client(self.server_params)
55
+ self.read_stream, self.write_stream = await self._context_stack.enter_async_context(
56
+ ctx
57
+ )
58
+
59
+ init_result = await send_initialize(self.read_stream, self.write_stream)
60
+ return bool(init_result)
61
+
62
+ except Exception as e: # pragma: no cover
63
+ import logging
64
+
65
+ logging.error(f"Error initializing stdio transport: {e}")
66
+ if self._context_stack:
67
+ try:
68
+ await self._context_stack.__aexit__(None, None, None)
69
+ except Exception:
70
+ pass
71
+ return False
72
+
73
+ async def close(self) -> None:
74
+ if self._context_stack:
75
+ try:
76
+ await self._context_stack.__aexit__(None, None, None)
77
+ except Exception:
78
+ pass
79
+ self.read_stream = None
80
+ self.write_stream = None
81
+ self._context_stack = None
82
+
83
+ # --------------------------------------------------------------------- #
84
+ # Utility #
85
+ # --------------------------------------------------------------------- #
86
+ async def send_ping(self) -> bool:
87
+ if not self.read_stream or not self.write_stream:
88
+ return False
89
+ return await send_ping(self.read_stream, self.write_stream)
90
+
91
+ async def get_tools(self) -> List[Dict[str, Any]]:
92
+ if not self.read_stream or not self.write_stream:
93
+ return []
94
+ tools_response = await send_tools_list(self.read_stream, self.write_stream)
95
+ return tools_response.get("tools", [])
96
+
97
+ # NEW ------------------------------------------------------------------ #
98
+ # Resources / Prompts #
99
+ # --------------------------------------------------------------------- #
100
+ async def list_resources(self) -> Dict[str, Any]:
101
+ """
102
+ Return the result of *resources/list*. If the connection is not yet
103
+ initialised an empty dict is returned.
104
+ """
105
+ if not self.read_stream or not self.write_stream:
106
+ return {}
107
+ try:
108
+ return await send_resources_list(self.read_stream, self.write_stream)
109
+ except Exception as exc: # pragma: no cover
110
+ import logging
111
+
112
+ logging.error(f"Error listing resources: {exc}")
113
+ return {}
114
+
115
+ async def list_prompts(self) -> Dict[str, Any]:
116
+ """
117
+ Return the result of *prompts/list*. If the connection is not yet
118
+ initialised an empty dict is returned.
119
+ """
120
+ if not self.read_stream or not self.write_stream:
121
+ return {}
122
+ try:
123
+ return await send_prompts_list(self.read_stream, self.write_stream)
124
+ except Exception as exc: # pragma: no cover
125
+ import logging
126
+
127
+ logging.error(f"Error listing prompts: {exc}")
128
+ return {}
129
+
130
+ # OPTIONAL helper ------------------------------------------------------ #
131
+ def get_streams(self):
132
+ """
133
+ Expose the low-level streams so legacy callers can access them
134
+ directly. The base-class’ default returns an empty list; here we
135
+ return a single-element list when the transport is active.
136
+ """
137
+ if self.read_stream and self.write_stream:
138
+ return [(self.read_stream, self.write_stream)]
139
+ return []
140
+
141
+ # --------------------------------------------------------------------- #
142
+ # Main entry-point #
143
+ # --------------------------------------------------------------------- #
144
+ async def call_tool(
145
+ self, tool_name: str, arguments: Dict[str, Any]
146
+ ) -> Dict[str, Any]:
147
+ """
148
+ Execute *tool_name* with *arguments* and normalise the server’s reply.
149
+
150
+ The echo-server often returns:
151
+ {
152
+ "content": [{"type":"text","text":"{\"message\":\"…\"}"}],
153
+ "isError": false
154
+ }
155
+ We unwrap that so callers just receive either a dict or a plain string.
156
+ """
157
+ if not self.read_stream or not self.write_stream:
158
+ return {"isError": True, "error": "Transport not initialized"}
159
+
160
+ try:
161
+ raw = await send_tools_call(
162
+ self.read_stream, self.write_stream, tool_name, arguments
163
+ )
164
+
165
+ # Handle explicit error wrapper
166
+ if "error" in raw:
167
+ return {
168
+ "isError": True,
169
+ "error": raw["error"].get("message", "Unknown error"),
170
+ }
171
+
172
+ # Preferred: servers that put the answer under "result"
173
+ if "result" in raw:
174
+ return {"isError": False, "content": raw["result"]}
175
+
176
+ # Common echo-server shape: top-level "content" list
177
+ if "content" in raw:
178
+ clist = raw["content"]
179
+ if isinstance(clist, list) and clist:
180
+ first = clist[0]
181
+ if isinstance(first, dict) and first.get("type") == "text":
182
+ text = first.get("text", "")
183
+ # Try to parse as JSON; fall back to plain string
184
+ try:
185
+ parsed = json.loads(text)
186
+ return {"isError": False, "content": parsed}
187
+ except json.JSONDecodeError:
188
+ return {"isError": False, "content": text}
189
+
190
+ # Fallback: give caller whatever the server sent
191
+ return {"isError": False, "content": raw}
192
+
193
+ except Exception as e: # pragma: no cover
194
+ import logging
195
+
196
+ logging.error(f"Error calling tool {tool_name}: {e}")
197
+ return {"isError": True, "error": str(e)}