dataify-sdk 0.1.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.
- dataify_mcp/__init__.py +37 -0
- dataify_mcp/_version.py +3 -0
- dataify_mcp/client/__init__.py +7 -0
- dataify_mcp/client/_base.py +223 -0
- dataify_mcp/client/_http.py +205 -0
- dataify_mcp/client/_protocol.py +204 -0
- dataify_mcp/client/_sse.py +319 -0
- dataify_mcp/tools/__init__.py +90 -0
- dataify_mcp/tools/amazon.py +137 -0
- dataify_mcp/tools/bing.py +141 -0
- dataify_mcp/tools/facebook.py +63 -0
- dataify_mcp/tools/glassdoor.py +41 -0
- dataify_mcp/tools/google_scraper.py +121 -0
- dataify_mcp/tools/google_serp.py +320 -0
- dataify_mcp/tools/indeed.py +41 -0
- dataify_mcp/tools/instagram.py +52 -0
- dataify_mcp/tools/linkedin.py +41 -0
- dataify_mcp/tools/other_scrapers.py +137 -0
- dataify_mcp/tools/other_search.py +90 -0
- dataify_mcp/tools/reddit.py +41 -0
- dataify_mcp/tools/task_status.py +188 -0
- dataify_mcp/tools/tiktok.py +81 -0
- dataify_mcp/tools/twitter.py +41 -0
- dataify_mcp/tools/user.py +79 -0
- dataify_mcp/tools/web_unlocker.py +53 -0
- dataify_mcp/tools/youtube.py +163 -0
- dataify_mcp/types/__init__.py +39 -0
- dataify_mcp/types/_enums.py +220 -0
- dataify_mcp/types/_errors.py +73 -0
- dataify_mcp/types/_mcp.py +89 -0
- dataify_sdk-0.1.0.dist-info/METADATA +154 -0
- dataify_sdk-0.1.0.dist-info/RECORD +34 -0
- dataify_sdk-0.1.0.dist-info/WHEEL +4 -0
- dataify_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
dataify_mcp/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Dataify MCP SDK — Python client for the Dataify MCP API.
|
|
2
|
+
|
|
3
|
+
Provides access to web unlocker, search engines (Google, Bing, Yandex, DuckDuckGo),
|
|
4
|
+
and platform scrapers (Amazon, YouTube, TikTok, Facebook, Instagram, etc.) through
|
|
5
|
+
the Model Context Protocol.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataify_mcp._version import __version__
|
|
9
|
+
from dataify_mcp.client._base import DataifyClient
|
|
10
|
+
from dataify_mcp.client._http import HTTPTransport
|
|
11
|
+
from dataify_mcp.client._sse import SSETransport
|
|
12
|
+
from dataify_mcp.types._errors import (
|
|
13
|
+
AuthenticationError,
|
|
14
|
+
ConnectionError,
|
|
15
|
+
DataifyError,
|
|
16
|
+
ProtocolError,
|
|
17
|
+
ServerError,
|
|
18
|
+
TimeoutError,
|
|
19
|
+
ToolAccessError,
|
|
20
|
+
ToolError,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"__version__",
|
|
25
|
+
"DataifyClient",
|
|
26
|
+
"HTTPTransport",
|
|
27
|
+
"SSETransport",
|
|
28
|
+
# Errors
|
|
29
|
+
"DataifyError",
|
|
30
|
+
"AuthenticationError",
|
|
31
|
+
"ToolAccessError",
|
|
32
|
+
"ConnectionError",
|
|
33
|
+
"ProtocolError",
|
|
34
|
+
"ToolError",
|
|
35
|
+
"TimeoutError",
|
|
36
|
+
"ServerError",
|
|
37
|
+
]
|
dataify_mcp/_version.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Client transports and the main DataifyClient."""
|
|
2
|
+
|
|
3
|
+
from dataify_mcp.client._base import DataifyClient
|
|
4
|
+
from dataify_mcp.client._http import HTTPTransport
|
|
5
|
+
from dataify_mcp.client._sse import SSETransport
|
|
6
|
+
|
|
7
|
+
__all__ = ["DataifyClient", "HTTPTransport", "SSETransport"]
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""DataifyClient — the main entry point for the Dataify MCP SDK.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from dataify_mcp import DataifyClient
|
|
7
|
+
|
|
8
|
+
async def main():
|
|
9
|
+
async with DataifyClient(
|
|
10
|
+
base_url="http://localhost:7780",
|
|
11
|
+
token="your-api-token",
|
|
12
|
+
) as client:
|
|
13
|
+
tools = await client.list_tools()
|
|
14
|
+
print(f"Available tools: {len(tools)}")
|
|
15
|
+
|
|
16
|
+
result = await client.call_tool("google_search", {"q": "weather"})
|
|
17
|
+
print(result)
|
|
18
|
+
|
|
19
|
+
asyncio.run(main())
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import logging
|
|
25
|
+
from types import TracebackType
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from dataify_mcp._version import __version__
|
|
29
|
+
from dataify_mcp.client._http import HTTPTransport
|
|
30
|
+
from dataify_mcp.client._protocol import MCPProtocol
|
|
31
|
+
from dataify_mcp.client._sse import SSETransport
|
|
32
|
+
from dataify_mcp.types._errors import ConnectionError
|
|
33
|
+
from dataify_mcp.types._mcp import ServerInfo, ToolDefinition
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
CLIENT_NAME = "dataify-mcp-sdk"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class DataifyClient:
|
|
41
|
+
"""Async client for the Dataify MCP API.
|
|
42
|
+
|
|
43
|
+
Wraps an MCP transport and protocol to provide a simple, Pythonic
|
|
44
|
+
interface for listing and calling MCP tools.
|
|
45
|
+
|
|
46
|
+
Parameters
|
|
47
|
+
----------
|
|
48
|
+
base_url:
|
|
49
|
+
Base URL of the MCP server, e.g. ``http://localhost:7780``.
|
|
50
|
+
token:
|
|
51
|
+
Dataify API token. Obtain from https://dashboard.dataify.com.
|
|
52
|
+
tool_codes:
|
|
53
|
+
Optional comma-separated list of ``class_code,tool_code`` pairs
|
|
54
|
+
to filter which tools are available. Without this parameter only
|
|
55
|
+
free tools are visible.
|
|
56
|
+
timeout:
|
|
57
|
+
HTTP request timeout in seconds (default 30).
|
|
58
|
+
transport:
|
|
59
|
+
Transport mode: ``"http"`` (default) or ``"sse"``.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
base_url: str,
|
|
65
|
+
token: str,
|
|
66
|
+
*,
|
|
67
|
+
tool_codes: str | None = None,
|
|
68
|
+
timeout: float = 30.0,
|
|
69
|
+
transport: str = "http",
|
|
70
|
+
) -> None:
|
|
71
|
+
self._base_url = base_url.rstrip("/")
|
|
72
|
+
self._token = token
|
|
73
|
+
self._tool_codes = tool_codes
|
|
74
|
+
self._timeout = timeout
|
|
75
|
+
|
|
76
|
+
self._protocol = MCPProtocol()
|
|
77
|
+
|
|
78
|
+
if transport == "sse":
|
|
79
|
+
self._transport = SSETransport(
|
|
80
|
+
base_url=base_url,
|
|
81
|
+
token=token,
|
|
82
|
+
tool_codes=tool_codes,
|
|
83
|
+
timeout=timeout,
|
|
84
|
+
)
|
|
85
|
+
else:
|
|
86
|
+
self._transport = HTTPTransport(
|
|
87
|
+
base_url=base_url,
|
|
88
|
+
token=token,
|
|
89
|
+
tool_codes=tool_codes,
|
|
90
|
+
timeout=timeout,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
self._transport_type = transport
|
|
94
|
+
|
|
95
|
+
# ------------------------------------------------------------------
|
|
96
|
+
# Context manager
|
|
97
|
+
# ------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
async def __aenter__(self) -> "DataifyClient":
|
|
100
|
+
await self.initialize()
|
|
101
|
+
return self
|
|
102
|
+
|
|
103
|
+
async def __aexit__(
|
|
104
|
+
self,
|
|
105
|
+
exc_type: type[BaseException] | None,
|
|
106
|
+
exc_val: BaseException | None,
|
|
107
|
+
exc_tb: TracebackType | None,
|
|
108
|
+
) -> None:
|
|
109
|
+
await self.close()
|
|
110
|
+
|
|
111
|
+
# ------------------------------------------------------------------
|
|
112
|
+
# Lifecycle
|
|
113
|
+
# ------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
async def initialize(self) -> ServerInfo:
|
|
116
|
+
"""Open the transport, perform the MCP handshake, and return server info.
|
|
117
|
+
|
|
118
|
+
This is called automatically when using the async context manager.
|
|
119
|
+
If you create a client manually you must call this before using it.
|
|
120
|
+
"""
|
|
121
|
+
await self._transport.open()
|
|
122
|
+
|
|
123
|
+
# Step 1: initialize
|
|
124
|
+
init_req = self._protocol.build_initialize_request(CLIENT_NAME, __version__)
|
|
125
|
+
init_resp = await self._transport.send_request(init_req)
|
|
126
|
+
info = self._protocol.process_initialize_response(init_resp)
|
|
127
|
+
|
|
128
|
+
logger.info(
|
|
129
|
+
"Connected to %s v%s (protocol %s)",
|
|
130
|
+
info.name,
|
|
131
|
+
info.version,
|
|
132
|
+
info.protocol_version,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# Step 2: send initialized notification
|
|
136
|
+
notif = self._protocol.build_initialized_notification()
|
|
137
|
+
await self._transport.send_notification(notif)
|
|
138
|
+
|
|
139
|
+
return info
|
|
140
|
+
|
|
141
|
+
async def close(self) -> None:
|
|
142
|
+
"""Close the underlying transport."""
|
|
143
|
+
await self._transport.close()
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def server_info(self) -> ServerInfo | None:
|
|
147
|
+
"""Information returned by the server during initialization."""
|
|
148
|
+
return self._protocol.server_info
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def is_initialized(self) -> bool:
|
|
152
|
+
"""Whether the MCP handshake has completed."""
|
|
153
|
+
return self._protocol.initialized
|
|
154
|
+
|
|
155
|
+
# ------------------------------------------------------------------
|
|
156
|
+
# MCP operations
|
|
157
|
+
# ------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
async def list_tools(self) -> list[ToolDefinition]:
|
|
160
|
+
"""Fetch the list of available tools from the server.
|
|
161
|
+
|
|
162
|
+
The returned list respects the ``tool_codes`` filter if one was
|
|
163
|
+
provided at construction time.
|
|
164
|
+
"""
|
|
165
|
+
if not self.is_initialized:
|
|
166
|
+
raise ConnectionError("Client not initialized. Call initialize() first.")
|
|
167
|
+
|
|
168
|
+
req = self._protocol.build_list_tools_request()
|
|
169
|
+
resp = await self._transport.send_request(req)
|
|
170
|
+
return self._protocol.process_list_tools_response(resp)
|
|
171
|
+
|
|
172
|
+
async def call_tool(self, name: str, arguments: dict[str, Any]) -> Any:
|
|
173
|
+
"""Call an MCP tool by name with the given keyword arguments.
|
|
174
|
+
|
|
175
|
+
Parameters
|
|
176
|
+
----------
|
|
177
|
+
name:
|
|
178
|
+
Tool name (e.g. ``"google_search"``, ``"request_web_unlocker"``).
|
|
179
|
+
arguments:
|
|
180
|
+
Tool parameters as a dictionary. All values are passed as
|
|
181
|
+
strings matching the server's expected format.
|
|
182
|
+
|
|
183
|
+
Returns
|
|
184
|
+
-------
|
|
185
|
+
Any
|
|
186
|
+
The tool result content (usually a dict).
|
|
187
|
+
|
|
188
|
+
Raises
|
|
189
|
+
------
|
|
190
|
+
ToolError
|
|
191
|
+
When the tool execution fails on the server side.
|
|
192
|
+
ConnectionError
|
|
193
|
+
When the transport is not initialized or has been closed.
|
|
194
|
+
"""
|
|
195
|
+
if not self.is_initialized:
|
|
196
|
+
raise ConnectionError("Client not initialized. Call initialize() first.")
|
|
197
|
+
|
|
198
|
+
req = self._protocol.build_call_tool_request(name, arguments)
|
|
199
|
+
resp = await self._transport.send_request(req)
|
|
200
|
+
return self._protocol.process_call_tool_response(resp)
|
|
201
|
+
|
|
202
|
+
async def ping(self) -> None:
|
|
203
|
+
"""Send a ping to verify the server is reachable."""
|
|
204
|
+
req = self._protocol.build_ping_request()
|
|
205
|
+
resp = await self._transport.send_request(req)
|
|
206
|
+
self._protocol.process_ping_response(resp)
|
|
207
|
+
|
|
208
|
+
# ------------------------------------------------------------------
|
|
209
|
+
# -- High-level convenience methods (auto-attached) --
|
|
210
|
+
#
|
|
211
|
+
# Typed tool methods (google_search, request_web_unlocker, etc.) are
|
|
212
|
+
# attached to this class at import time by ``dataify_mcp.tools.attach_all``.
|
|
213
|
+
# See ``src/dataify_mcp/tools/__init__.py``.
|
|
214
|
+
#
|
|
215
|
+
# When the server adds new tools, regenerate the tool modules by running:
|
|
216
|
+
# python scripts/codegen.py --server http://localhost:7780
|
|
217
|
+
# ------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# Attach all typed tool methods to the DataifyClient class at import time.
|
|
221
|
+
from dataify_mcp.tools import attach_all # noqa: E402
|
|
222
|
+
|
|
223
|
+
attach_all(DataifyClient)
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Streamable HTTP transport for MCP.
|
|
2
|
+
|
|
3
|
+
Implements the Streamable HTTP transport defined by the MCP spec:
|
|
4
|
+
POST ``/mcp`` with JSON-RPC bodies; the server may return the response
|
|
5
|
+
directly or stream it via SSE.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from dataify_mcp.types._errors import (
|
|
18
|
+
ConnectionError,
|
|
19
|
+
ProtocolError,
|
|
20
|
+
ServerError,
|
|
21
|
+
TimeoutError,
|
|
22
|
+
)
|
|
23
|
+
from dataify_mcp.types._mcp import JSONRPCError, JSONRPCRequest, JSONRPCResponse
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
# Headers used by the MCP Streamable HTTP transport.
|
|
28
|
+
HEADER_SESSION_ID = "Mcp-Session-Id"
|
|
29
|
+
HEADER_LAST_EVENT_ID = "Last-Event-ID"
|
|
30
|
+
HEADER_CONTENT_TYPE = "Content-Type"
|
|
31
|
+
MIME_JSON = "application/json"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class HTTPTransport:
|
|
36
|
+
"""Streamable HTTP transport that talks to the ``/mcp`` endpoint.
|
|
37
|
+
|
|
38
|
+
Parameters
|
|
39
|
+
----------
|
|
40
|
+
base_url:
|
|
41
|
+
Base URL of the MCP server, e.g. ``http://localhost:7780``.
|
|
42
|
+
Must not include a trailing slash.
|
|
43
|
+
token:
|
|
44
|
+
Dataify API token passed as the ``?token=`` query parameter.
|
|
45
|
+
tool_codes:
|
|
46
|
+
Optional comma-separated tool codes passed as ``?tools=``.
|
|
47
|
+
timeout:
|
|
48
|
+
HTTP request timeout in seconds (default 30).
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
base_url: str
|
|
52
|
+
token: str
|
|
53
|
+
tool_codes: str | None = None
|
|
54
|
+
timeout: float = 30.0
|
|
55
|
+
|
|
56
|
+
# --- internal state -------------------------------------------------
|
|
57
|
+
|
|
58
|
+
_client: httpx.AsyncClient | None = None
|
|
59
|
+
_session_id: str | None = None
|
|
60
|
+
|
|
61
|
+
# --- URL construction -----------------------------------------------
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def endpoint(self) -> str:
|
|
65
|
+
"""Full URL of the MCP endpoint including auth query params."""
|
|
66
|
+
url = f"{self.base_url.rstrip('/')}/mcp?token={self.token}"
|
|
67
|
+
if self.tool_codes:
|
|
68
|
+
url += f"&tools={self.tool_codes}"
|
|
69
|
+
return url
|
|
70
|
+
|
|
71
|
+
# --- lifecycle ------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
async def open(self) -> None:
|
|
74
|
+
"""Create the underlying HTTP client if not already open."""
|
|
75
|
+
if self._client is not None:
|
|
76
|
+
return
|
|
77
|
+
self._client = httpx.AsyncClient(timeout=httpx.Timeout(self.timeout))
|
|
78
|
+
|
|
79
|
+
async def close(self) -> None:
|
|
80
|
+
"""Close the underlying HTTP client."""
|
|
81
|
+
if self._client is not None:
|
|
82
|
+
await self._client.aclose()
|
|
83
|
+
self._client = None
|
|
84
|
+
self._session_id = None
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def is_open(self) -> bool:
|
|
88
|
+
"""Check whether the transport is currently open."""
|
|
89
|
+
return self._client is not None and not self._client.is_closed
|
|
90
|
+
|
|
91
|
+
# --- request/response -----------------------------------------------
|
|
92
|
+
|
|
93
|
+
async def send_request(self, request: JSONRPCRequest) -> JSONRPCResponse:
|
|
94
|
+
"""Send a JSON-RPC request and return the parsed response.
|
|
95
|
+
|
|
96
|
+
Raises
|
|
97
|
+
------
|
|
98
|
+
ConnectionError
|
|
99
|
+
On network-level failures.
|
|
100
|
+
ProtocolError
|
|
101
|
+
When the server response is not valid JSON or lacks the expected fields.
|
|
102
|
+
ServerError
|
|
103
|
+
When the server returns HTTP 5xx.
|
|
104
|
+
TimeoutError
|
|
105
|
+
When the request exceeds the configured timeout.
|
|
106
|
+
"""
|
|
107
|
+
if self._client is None:
|
|
108
|
+
raise ConnectionError("Transport is not open. Call open() first.")
|
|
109
|
+
|
|
110
|
+
headers: dict[str, str] = {
|
|
111
|
+
HEADER_CONTENT_TYPE: MIME_JSON,
|
|
112
|
+
"Accept": MIME_JSON,
|
|
113
|
+
}
|
|
114
|
+
if self._session_id:
|
|
115
|
+
headers[HEADER_SESSION_ID] = self._session_id
|
|
116
|
+
|
|
117
|
+
body = {
|
|
118
|
+
"jsonrpc": request.jsonrpc,
|
|
119
|
+
"id": request.id,
|
|
120
|
+
"method": request.method,
|
|
121
|
+
"params": request.params or {},
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
logger.debug("MCP request id=%d method=%s", request.id, request.method)
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
http_resp = await self._client.post(
|
|
128
|
+
self.endpoint,
|
|
129
|
+
json=body,
|
|
130
|
+
headers=headers,
|
|
131
|
+
)
|
|
132
|
+
except httpx.TimeoutException as exc:
|
|
133
|
+
raise TimeoutError(
|
|
134
|
+
f"Request id={request.id} method={request.method} timed out "
|
|
135
|
+
f"after {self.timeout}s"
|
|
136
|
+
) from exc
|
|
137
|
+
except httpx.NetworkError as exc:
|
|
138
|
+
raise ConnectionError(
|
|
139
|
+
f"Network error sending request id={request.id}: {exc}"
|
|
140
|
+
) from exc
|
|
141
|
+
|
|
142
|
+
# Capture session id from server response
|
|
143
|
+
session_id = http_resp.headers.get(HEADER_SESSION_ID)
|
|
144
|
+
if session_id:
|
|
145
|
+
self._session_id = session_id
|
|
146
|
+
|
|
147
|
+
# Handle HTTP errors
|
|
148
|
+
if http_resp.status_code >= 500:
|
|
149
|
+
raise ServerError(
|
|
150
|
+
f"Server returned HTTP {http_resp.status_code}: {http_resp.text[:500]}"
|
|
151
|
+
)
|
|
152
|
+
if http_resp.status_code == 401 or http_resp.status_code == 403:
|
|
153
|
+
from dataify_mcp.types._errors import AuthenticationError
|
|
154
|
+
|
|
155
|
+
raise AuthenticationError(
|
|
156
|
+
f"Authentication failed (HTTP {http_resp.status_code}). "
|
|
157
|
+
"Check your API token at https://dashboard.dataify.com"
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# Try to parse the response body as JSON
|
|
161
|
+
try:
|
|
162
|
+
data = http_resp.json()
|
|
163
|
+
except json.JSONDecodeError as exc:
|
|
164
|
+
raise ProtocolError(
|
|
165
|
+
f"Invalid JSON response for id={request.id}: {http_resp.text[:500]}"
|
|
166
|
+
) from exc
|
|
167
|
+
|
|
168
|
+
# Build JSONRPCResponse
|
|
169
|
+
return JSONRPCResponse(
|
|
170
|
+
jsonrpc=data.get("jsonrpc", "2.0"),
|
|
171
|
+
id=data.get("id", request.id),
|
|
172
|
+
result=data.get("result"),
|
|
173
|
+
error=JSONRPCError(**data["error"]) if "error" in data and data["error"] else None,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
async def send_notification(self, request: JSONRPCRequest) -> None:
|
|
177
|
+
"""Send a JSON-RPC notification (no response expected)."""
|
|
178
|
+
if self._client is None:
|
|
179
|
+
raise ConnectionError("Transport is not open. Call open() first.")
|
|
180
|
+
|
|
181
|
+
headers: dict[str, str] = {
|
|
182
|
+
HEADER_CONTENT_TYPE: MIME_JSON,
|
|
183
|
+
"Accept": MIME_JSON,
|
|
184
|
+
}
|
|
185
|
+
if self._session_id:
|
|
186
|
+
headers[HEADER_SESSION_ID] = self._session_id
|
|
187
|
+
|
|
188
|
+
body = {
|
|
189
|
+
"jsonrpc": request.jsonrpc,
|
|
190
|
+
"method": request.method,
|
|
191
|
+
"params": request.params or {},
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
logger.debug("MCP notification method=%s", request.method)
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
await self._client.post(
|
|
198
|
+
self.endpoint,
|
|
199
|
+
json=body,
|
|
200
|
+
headers=headers,
|
|
201
|
+
)
|
|
202
|
+
except httpx.TimeoutException:
|
|
203
|
+
logger.warning("Notification %s timed out (ignoring)", request.method)
|
|
204
|
+
except httpx.NetworkError as exc:
|
|
205
|
+
logger.warning("Notification %s failed: %s", request.method, exc)
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""MCP protocol lifecycle management.
|
|
2
|
+
|
|
3
|
+
Handles JSON-RPC 2.0 message IDs, the ``initialize`` → ``initialized``
|
|
4
|
+
handshake, ``tools/list``, ``tools/call``, and ``ping``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from dataify_mcp.types._errors import ERROR_CODE_MAP, ProtocolError, ToolError
|
|
13
|
+
from dataify_mcp.types._mcp import (
|
|
14
|
+
JSONRPCError,
|
|
15
|
+
JSONRPCRequest,
|
|
16
|
+
JSONRPCResponse,
|
|
17
|
+
ServerInfo,
|
|
18
|
+
ToolDefinition,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
# MCP protocol version requested by this SDK.
|
|
24
|
+
MCP_PROTOCOL_VERSION = "2024-11-05"
|
|
25
|
+
|
|
26
|
+
# Standard MCP method names.
|
|
27
|
+
METHOD_INITIALIZE = "initialize"
|
|
28
|
+
METHOD_INITIALIZED = "notifications/initialized"
|
|
29
|
+
METHOD_TOOLS_LIST = "tools/list"
|
|
30
|
+
METHOD_TOOLS_CALL = "tools/call"
|
|
31
|
+
METHOD_PING = "ping"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class MCPProtocol:
|
|
35
|
+
"""Manages the MCP JSON-RPC conversation with a single transport.
|
|
36
|
+
|
|
37
|
+
The protocol is responsible for:
|
|
38
|
+
|
|
39
|
+
* Tracking monotonically increasing message IDs.
|
|
40
|
+
* Performing the MCP handshake (``initialize`` + ``initialized``).
|
|
41
|
+
* Exposing high-level methods that match MCP capabilities
|
|
42
|
+
(``list_tools``, ``call_tool``, ``ping``).
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self) -> None:
|
|
46
|
+
self._next_id = 0
|
|
47
|
+
self._server_info: ServerInfo | None = None
|
|
48
|
+
self._initialized = False
|
|
49
|
+
|
|
50
|
+
# ------------------------------------------------------------------
|
|
51
|
+
# ID management
|
|
52
|
+
# ------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
def _allocate_id(self) -> int:
|
|
55
|
+
"""Return the next JSON-RPC message id."""
|
|
56
|
+
self._next_id += 1
|
|
57
|
+
return self._next_id
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def server_info(self) -> ServerInfo | None:
|
|
61
|
+
"""Information returned by the server during initialization."""
|
|
62
|
+
return self._server_info
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def initialized(self) -> bool:
|
|
66
|
+
"""Whether the MCP handshake has completed."""
|
|
67
|
+
return self._initialized
|
|
68
|
+
|
|
69
|
+
# ------------------------------------------------------------------
|
|
70
|
+
# Request builders
|
|
71
|
+
# ------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
def build_initialize_request(self, client_name: str, client_version: str) -> JSONRPCRequest:
|
|
74
|
+
"""Build the ``initialize`` request."""
|
|
75
|
+
return JSONRPCRequest(
|
|
76
|
+
method=METHOD_INITIALIZE,
|
|
77
|
+
id=self._allocate_id(),
|
|
78
|
+
params={
|
|
79
|
+
"protocolVersion": MCP_PROTOCOL_VERSION,
|
|
80
|
+
"capabilities": {},
|
|
81
|
+
"clientInfo": {
|
|
82
|
+
"name": client_name,
|
|
83
|
+
"version": client_version,
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def build_initialized_notification(self) -> JSONRPCRequest:
|
|
89
|
+
"""Build the ``notifications/initialized`` notification."""
|
|
90
|
+
return JSONRPCRequest(
|
|
91
|
+
method=METHOD_INITIALIZED,
|
|
92
|
+
id=self._allocate_id(),
|
|
93
|
+
params={},
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def build_list_tools_request(self) -> JSONRPCRequest:
|
|
97
|
+
"""Build the ``tools/list`` request."""
|
|
98
|
+
return JSONRPCRequest(
|
|
99
|
+
method=METHOD_TOOLS_LIST,
|
|
100
|
+
id=self._allocate_id(),
|
|
101
|
+
params={},
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def build_call_tool_request(self, name: str, arguments: dict[str, Any]) -> JSONRPCRequest:
|
|
105
|
+
"""Build the ``tools/call`` request."""
|
|
106
|
+
return JSONRPCRequest(
|
|
107
|
+
method=METHOD_TOOLS_CALL,
|
|
108
|
+
id=self._allocate_id(),
|
|
109
|
+
params={"name": name, "arguments": arguments},
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def build_ping_request(self) -> JSONRPCRequest:
|
|
113
|
+
"""Build the ``ping`` request."""
|
|
114
|
+
return JSONRPCRequest(
|
|
115
|
+
method=METHOD_PING,
|
|
116
|
+
id=self._allocate_id(),
|
|
117
|
+
params={},
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# ------------------------------------------------------------------
|
|
121
|
+
# Response processors
|
|
122
|
+
# ------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
def process_initialize_response(self, response: JSONRPCResponse) -> ServerInfo:
|
|
125
|
+
"""Validate and extract server info from an ``initialize`` response."""
|
|
126
|
+
self._check_response(response)
|
|
127
|
+
result = response.result
|
|
128
|
+
|
|
129
|
+
capabilities_raw = result.get("capabilities", {})
|
|
130
|
+
from dataify_mcp.types._mcp import MCPServerCapabilities
|
|
131
|
+
|
|
132
|
+
info = ServerInfo(
|
|
133
|
+
name=result.get("serverInfo", {}).get("name", "unknown"),
|
|
134
|
+
version=result.get("serverInfo", {}).get("version", "0.0.0"),
|
|
135
|
+
protocol_version=result.get("protocolVersion", ""),
|
|
136
|
+
capabilities=MCPServerCapabilities(
|
|
137
|
+
tools=capabilities_raw.get("tools"),
|
|
138
|
+
resources=capabilities_raw.get("resources"),
|
|
139
|
+
prompts=capabilities_raw.get("prompts"),
|
|
140
|
+
logging=capabilities_raw.get("logging"),
|
|
141
|
+
experimental=capabilities_raw.get("experimental"),
|
|
142
|
+
),
|
|
143
|
+
instructions=result.get("instructions"),
|
|
144
|
+
)
|
|
145
|
+
self._server_info = info
|
|
146
|
+
self._initialized = True
|
|
147
|
+
return info
|
|
148
|
+
|
|
149
|
+
def process_list_tools_response(self, response: JSONRPCResponse) -> list[ToolDefinition]:
|
|
150
|
+
"""Extract tool definitions from a ``tools/list`` response."""
|
|
151
|
+
self._check_response(response)
|
|
152
|
+
tools_data: list[dict[str, Any]] = response.result.get("tools", [])
|
|
153
|
+
return [
|
|
154
|
+
ToolDefinition(
|
|
155
|
+
name=t["name"],
|
|
156
|
+
description=t.get("description", ""),
|
|
157
|
+
inputSchema=t.get("inputSchema", {}),
|
|
158
|
+
)
|
|
159
|
+
for t in tools_data
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
def process_call_tool_response(self, response: JSONRPCResponse) -> Any:
|
|
163
|
+
"""Extract the tool result from a ``tools/call`` response.
|
|
164
|
+
|
|
165
|
+
Returns the ``content`` field of the result. Raises ``ToolError``
|
|
166
|
+
when ``isError`` is true.
|
|
167
|
+
"""
|
|
168
|
+
self._check_response(response)
|
|
169
|
+
|
|
170
|
+
# MCP tools/call result may be nested inside content array
|
|
171
|
+
result = response.result
|
|
172
|
+
is_error = result.get("isError", False)
|
|
173
|
+
|
|
174
|
+
content = result.get("content", result)
|
|
175
|
+
if isinstance(content, list) and len(content) == 1:
|
|
176
|
+
item = content[0]
|
|
177
|
+
if isinstance(item, dict) and "text" in item:
|
|
178
|
+
content = item["text"]
|
|
179
|
+
elif isinstance(item, dict) and "data" in item:
|
|
180
|
+
content = item["data"]
|
|
181
|
+
else:
|
|
182
|
+
content = item
|
|
183
|
+
|
|
184
|
+
if is_error:
|
|
185
|
+
error_text = content if isinstance(content, str) else result.get("content", [{}])[0].get("text", str(content)) if isinstance(result.get("content"), list) else str(content)
|
|
186
|
+
raise ToolError(error_text)
|
|
187
|
+
|
|
188
|
+
return content
|
|
189
|
+
|
|
190
|
+
def process_ping_response(self, response: JSONRPCResponse) -> None:
|
|
191
|
+
"""Validate a ``ping`` response (no-op on success)."""
|
|
192
|
+
self._check_response(response)
|
|
193
|
+
|
|
194
|
+
# ------------------------------------------------------------------
|
|
195
|
+
# Helpers
|
|
196
|
+
# ------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
@staticmethod
|
|
199
|
+
def _check_response(response: JSONRPCResponse) -> None:
|
|
200
|
+
"""Raise the appropriate SDK exception if the response is an error."""
|
|
201
|
+
if response.error is not None:
|
|
202
|
+
err = response.error
|
|
203
|
+
exc_cls = ERROR_CODE_MAP.get(err.code, ProtocolError)
|
|
204
|
+
raise exc_cls(f"[{err.code}] {err.message}" + (f": {err.data}" if err.data else ""))
|