abstractcore 2.6.8__py3-none-any.whl → 2.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.
- abstractcore/apps/summarizer.py +69 -27
- abstractcore/architectures/detection.py +190 -25
- abstractcore/assets/architecture_formats.json +129 -6
- abstractcore/assets/model_capabilities.json +789 -136
- abstractcore/config/main.py +2 -2
- abstractcore/config/manager.py +3 -1
- abstractcore/events/__init__.py +7 -1
- abstractcore/mcp/__init__.py +30 -0
- abstractcore/mcp/client.py +213 -0
- abstractcore/mcp/factory.py +64 -0
- abstractcore/mcp/naming.py +28 -0
- abstractcore/mcp/stdio_client.py +336 -0
- abstractcore/mcp/tool_source.py +164 -0
- abstractcore/processing/basic_deepsearch.py +1 -1
- abstractcore/processing/basic_summarizer.py +300 -83
- abstractcore/providers/anthropic_provider.py +91 -10
- abstractcore/providers/base.py +537 -16
- abstractcore/providers/huggingface_provider.py +17 -8
- abstractcore/providers/lmstudio_provider.py +170 -25
- abstractcore/providers/mlx_provider.py +13 -10
- abstractcore/providers/ollama_provider.py +42 -26
- abstractcore/providers/openai_compatible_provider.py +87 -22
- abstractcore/providers/openai_provider.py +12 -9
- abstractcore/providers/streaming.py +201 -39
- abstractcore/providers/vllm_provider.py +78 -21
- abstractcore/server/app.py +65 -28
- abstractcore/structured/retry.py +20 -7
- abstractcore/tools/__init__.py +5 -4
- abstractcore/tools/abstractignore.py +166 -0
- abstractcore/tools/arg_canonicalizer.py +61 -0
- abstractcore/tools/common_tools.py +2311 -772
- abstractcore/tools/core.py +109 -13
- abstractcore/tools/handler.py +17 -3
- abstractcore/tools/parser.py +798 -155
- abstractcore/tools/registry.py +107 -2
- abstractcore/tools/syntax_rewriter.py +68 -6
- abstractcore/tools/tag_rewriter.py +186 -1
- abstractcore/utils/jsonish.py +111 -0
- abstractcore/utils/version.py +1 -1
- {abstractcore-2.6.8.dist-info → abstractcore-2.9.0.dist-info}/METADATA +11 -2
- {abstractcore-2.6.8.dist-info → abstractcore-2.9.0.dist-info}/RECORD +45 -36
- {abstractcore-2.6.8.dist-info → abstractcore-2.9.0.dist-info}/WHEEL +0 -0
- {abstractcore-2.6.8.dist-info → abstractcore-2.9.0.dist-info}/entry_points.txt +0 -0
- {abstractcore-2.6.8.dist-info → abstractcore-2.9.0.dist-info}/licenses/LICENSE +0 -0
- {abstractcore-2.6.8.dist-info → abstractcore-2.9.0.dist-info}/top_level.txt +0 -0
abstractcore/config/main.py
CHANGED
|
@@ -265,7 +265,7 @@ def add_arguments(parser: argparse.ArgumentParser):
|
|
|
265
265
|
# Timeout configuration group
|
|
266
266
|
timeout_group = parser.add_argument_group('Timeout Configuration')
|
|
267
267
|
timeout_group.add_argument("--set-default-timeout", type=float, metavar="SECONDS",
|
|
268
|
-
help="Set default HTTP request timeout in seconds (default:
|
|
268
|
+
help="Set default HTTP request timeout in seconds (default: 7200 = 2 hours)")
|
|
269
269
|
timeout_group.add_argument("--set-tool-timeout", type=float, metavar="SECONDS",
|
|
270
270
|
help="Set tool execution timeout in seconds (default: 600 = 10 minutes)")
|
|
271
271
|
|
|
@@ -463,7 +463,7 @@ def print_status():
|
|
|
463
463
|
print("│ abstractcore --set-default-cache-dir PATH")
|
|
464
464
|
print("│")
|
|
465
465
|
print("│ ⏱️ Performance & Timeouts")
|
|
466
|
-
print("│ abstractcore --set-default-timeout SECONDS (HTTP requests, default:
|
|
466
|
+
print("│ abstractcore --set-default-timeout SECONDS (HTTP requests, default: 7200)")
|
|
467
467
|
print("│ abstractcore --set-tool-timeout SECONDS (Tool execution, default: 600)")
|
|
468
468
|
print("│")
|
|
469
469
|
print("│ 🎯 Specialized Models")
|
abstractcore/config/manager.py
CHANGED
|
@@ -88,7 +88,9 @@ class LoggingConfig:
|
|
|
88
88
|
@dataclass
|
|
89
89
|
class TimeoutConfig:
|
|
90
90
|
"""Timeout configuration settings."""
|
|
91
|
-
|
|
91
|
+
# Default HTTP timeout for LLM providers (in seconds).
|
|
92
|
+
# This is used as the *process-wide* default unless overridden per-provider/per-call.
|
|
93
|
+
default_timeout: float = 7200.0 # 2 hours
|
|
92
94
|
tool_timeout: float = 600.0 # 10 minutes for tool execution (in seconds)
|
|
93
95
|
|
|
94
96
|
|
abstractcore/events/__init__.py
CHANGED
|
@@ -47,6 +47,12 @@ class EventType(Enum):
|
|
|
47
47
|
COMPACTION_STARTED = "compaction_started" # When chat history compaction begins
|
|
48
48
|
COMPACTION_COMPLETED = "compaction_completed" # When compaction finishes
|
|
49
49
|
|
|
50
|
+
# Runtime/workflow events (4) - durable execution progress (StepRecord)
|
|
51
|
+
WORKFLOW_STEP_STARTED = "workflow_step_started"
|
|
52
|
+
WORKFLOW_STEP_COMPLETED = "workflow_step_completed"
|
|
53
|
+
WORKFLOW_STEP_WAITING = "workflow_step_waiting"
|
|
54
|
+
WORKFLOW_STEP_FAILED = "workflow_step_failed"
|
|
55
|
+
|
|
50
56
|
|
|
51
57
|
@dataclass
|
|
52
58
|
class Event:
|
|
@@ -428,4 +434,4 @@ class PerformanceTracker:
|
|
|
428
434
|
|
|
429
435
|
def get_metrics(self) -> Dict[str, Any]:
|
|
430
436
|
"""Get current metrics"""
|
|
431
|
-
return self.metrics.copy()
|
|
437
|
+
return self.metrics.copy()
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MCP (Model Context Protocol) integration.
|
|
3
|
+
|
|
4
|
+
This package provides:
|
|
5
|
+
- A minimal JSON-RPC client for MCP servers (Streamable HTTP transport).
|
|
6
|
+
- Tool discovery via `tools/list`.
|
|
7
|
+
- Schema normalization into AbstractCore-compatible tool specs (dicts accepted by `tools=...`).
|
|
8
|
+
|
|
9
|
+
MCP is treated as a *tool server protocol* (not an LLM provider). Tool execution remains
|
|
10
|
+
explicitly host/runtime-owned via a ToolExecutor boundary.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from .client import McpClient
|
|
14
|
+
from .factory import create_mcp_client
|
|
15
|
+
from .naming import MCP_TOOL_PREFIX, namespaced_tool_name, parse_namespaced_tool_name
|
|
16
|
+
from .stdio_client import McpStdioClient, McpStdioServerParameters
|
|
17
|
+
from .tool_source import McpServerInfo, McpToolSource, mcp_tool_to_abstractcore_tool_spec
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"McpClient",
|
|
21
|
+
"McpStdioClient",
|
|
22
|
+
"McpStdioServerParameters",
|
|
23
|
+
"McpServerInfo",
|
|
24
|
+
"McpToolSource",
|
|
25
|
+
"MCP_TOOL_PREFIX",
|
|
26
|
+
"create_mcp_client",
|
|
27
|
+
"namespaced_tool_name",
|
|
28
|
+
"parse_namespaced_tool_name",
|
|
29
|
+
"mcp_tool_to_abstractcore_tool_spec",
|
|
30
|
+
]
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import itertools
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
_DEFAULT_ACCEPT = "application/json, text/event-stream"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class McpError(RuntimeError):
|
|
14
|
+
"""Base error for MCP client failures."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class McpHttpError(McpError):
|
|
18
|
+
"""Raised when an MCP HTTP request fails (non-2xx, invalid JSON)."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class McpRpcError(McpError):
|
|
22
|
+
"""Raised when an MCP JSON-RPC response contains an error object."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, *, code: int, message: str, data: Any = None):
|
|
25
|
+
super().__init__(f"MCP JSON-RPC error {code}: {message}")
|
|
26
|
+
self.code = int(code)
|
|
27
|
+
self.message = str(message)
|
|
28
|
+
self.data = data
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class McpProtocolError(McpError):
|
|
32
|
+
"""Raised when an MCP response is malformed or violates JSON-RPC expectations."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class McpJsonRpcRequest:
|
|
37
|
+
jsonrpc: str
|
|
38
|
+
id: int
|
|
39
|
+
method: str
|
|
40
|
+
params: Optional[Dict[str, Any]] = None
|
|
41
|
+
|
|
42
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
43
|
+
payload: Dict[str, Any] = {"jsonrpc": self.jsonrpc, "id": self.id, "method": self.method}
|
|
44
|
+
if self.params is not None:
|
|
45
|
+
payload["params"] = self.params
|
|
46
|
+
return payload
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class McpClient:
|
|
50
|
+
"""A minimal MCP JSON-RPC client using HTTP POST (Streamable HTTP transport).
|
|
51
|
+
|
|
52
|
+
This is intentionally small: it focuses on the tools surface (`tools/list`, `tools/call`).
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
@staticmethod
|
|
56
|
+
def _ensure_accept_header(client: httpx.Client) -> None:
|
|
57
|
+
existing = str(client.headers.get("Accept") or "").strip()
|
|
58
|
+
if not existing:
|
|
59
|
+
client.headers["Accept"] = _DEFAULT_ACCEPT
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
# httpx defaults to "*/*"; some MCP servers require both application/json and
|
|
63
|
+
# text/event-stream in Accept for streamable HTTP.
|
|
64
|
+
if existing == "*/*":
|
|
65
|
+
client.headers["Accept"] = _DEFAULT_ACCEPT
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
has_json = "application/json" in existing
|
|
69
|
+
has_sse = "text/event-stream" in existing
|
|
70
|
+
if has_json and has_sse:
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
# Preserve any custom values while guaranteeing the required types appear.
|
|
74
|
+
parts = [p.strip() for p in existing.split(",") if p.strip()]
|
|
75
|
+
# Drop wildcard because some servers treat it as insufficient.
|
|
76
|
+
parts = [p for p in parts if p != "*/*"]
|
|
77
|
+
|
|
78
|
+
required = ["application/json", "text/event-stream"]
|
|
79
|
+
out: List[str] = []
|
|
80
|
+
for r in required:
|
|
81
|
+
if any(r in p for p in parts):
|
|
82
|
+
continue
|
|
83
|
+
out.append(r)
|
|
84
|
+
out.extend(parts)
|
|
85
|
+
client.headers["Accept"] = ", ".join(out) if out else _DEFAULT_ACCEPT
|
|
86
|
+
|
|
87
|
+
def __init__(
|
|
88
|
+
self,
|
|
89
|
+
*,
|
|
90
|
+
url: str,
|
|
91
|
+
headers: Optional[Dict[str, str]] = None,
|
|
92
|
+
timeout_s: Optional[float] = 30.0,
|
|
93
|
+
protocol_version: Optional[str] = None,
|
|
94
|
+
session_id: Optional[str] = None,
|
|
95
|
+
client: Optional[httpx.Client] = None,
|
|
96
|
+
) -> None:
|
|
97
|
+
self._url = str(url or "").strip()
|
|
98
|
+
if not self._url:
|
|
99
|
+
raise ValueError("McpClient requires a non-empty url")
|
|
100
|
+
|
|
101
|
+
self._timeout_s = timeout_s
|
|
102
|
+
self._client = client or httpx.Client(headers=headers, timeout=timeout_s)
|
|
103
|
+
self._owns_client = client is None
|
|
104
|
+
self._id_iter = itertools.count(1)
|
|
105
|
+
self._session_id: Optional[str] = str(session_id).strip() if session_id else None
|
|
106
|
+
|
|
107
|
+
# Ensure streamable HTTP compatibility by default.
|
|
108
|
+
self._ensure_accept_header(self._client)
|
|
109
|
+
if protocol_version:
|
|
110
|
+
self._client.headers["MCP-Protocol-Version"] = str(protocol_version).strip()
|
|
111
|
+
if self._session_id:
|
|
112
|
+
self._client.headers["MCP-Session-Id"] = self._session_id
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def url(self) -> str:
|
|
116
|
+
return self._url
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def session_id(self) -> Optional[str]:
|
|
120
|
+
return self._session_id
|
|
121
|
+
|
|
122
|
+
def close(self) -> None:
|
|
123
|
+
if self._owns_client:
|
|
124
|
+
self._client.close()
|
|
125
|
+
|
|
126
|
+
def __enter__(self) -> "McpClient":
|
|
127
|
+
return self
|
|
128
|
+
|
|
129
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
130
|
+
self.close()
|
|
131
|
+
|
|
132
|
+
def _post_json(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
133
|
+
try:
|
|
134
|
+
resp = self._client.post(self._url, json=payload)
|
|
135
|
+
except Exception as e:
|
|
136
|
+
raise McpHttpError(f"MCP request failed: {e}") from e
|
|
137
|
+
|
|
138
|
+
# Capture MCP session id if the server provides one (streamable HTTP sessions).
|
|
139
|
+
sid = str(resp.headers.get("MCP-Session-Id") or "").strip()
|
|
140
|
+
if sid and sid != self._session_id:
|
|
141
|
+
self._session_id = sid
|
|
142
|
+
self._client.headers["MCP-Session-Id"] = sid
|
|
143
|
+
|
|
144
|
+
if resp.status_code < 200 or resp.status_code >= 300:
|
|
145
|
+
body = (resp.text or "").strip()
|
|
146
|
+
raise McpHttpError(f"MCP HTTP {resp.status_code}: {body[:500]}")
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
data = resp.json()
|
|
150
|
+
except Exception as e:
|
|
151
|
+
raise McpHttpError(f"MCP response is not valid JSON: {e}") from e
|
|
152
|
+
|
|
153
|
+
if not isinstance(data, dict):
|
|
154
|
+
raise McpProtocolError("MCP JSON-RPC response must be an object")
|
|
155
|
+
return data
|
|
156
|
+
|
|
157
|
+
def request(self, *, method: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
158
|
+
mid = str(method or "").strip()
|
|
159
|
+
if not mid:
|
|
160
|
+
raise ValueError("MCP request requires a non-empty method")
|
|
161
|
+
|
|
162
|
+
req = McpJsonRpcRequest(jsonrpc="2.0", id=next(self._id_iter), method=mid, params=params)
|
|
163
|
+
resp = self._post_json(req.to_dict())
|
|
164
|
+
|
|
165
|
+
if resp.get("jsonrpc") != "2.0":
|
|
166
|
+
raise McpProtocolError("MCP response missing jsonrpc='2.0'")
|
|
167
|
+
|
|
168
|
+
if "error" in resp and resp["error"] is not None:
|
|
169
|
+
err = resp["error"]
|
|
170
|
+
if isinstance(err, dict):
|
|
171
|
+
raise McpRpcError(
|
|
172
|
+
code=int(err.get("code") or 0),
|
|
173
|
+
message=str(err.get("message") or "Unknown error"),
|
|
174
|
+
data=err.get("data"),
|
|
175
|
+
)
|
|
176
|
+
raise McpRpcError(code=-32000, message=str(err), data=None)
|
|
177
|
+
|
|
178
|
+
resp_id = resp.get("id")
|
|
179
|
+
if resp_id is None:
|
|
180
|
+
raise McpProtocolError("MCP response missing id")
|
|
181
|
+
if str(resp_id) != str(req.id):
|
|
182
|
+
raise McpProtocolError(f"MCP response id mismatch (expected {req.id}, got {resp_id})")
|
|
183
|
+
|
|
184
|
+
result = resp.get("result")
|
|
185
|
+
if not isinstance(result, dict):
|
|
186
|
+
raise McpProtocolError("MCP response missing result object")
|
|
187
|
+
return result
|
|
188
|
+
|
|
189
|
+
def list_tools(self, *, cursor: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
190
|
+
params: Optional[Dict[str, Any]] = None
|
|
191
|
+
if cursor is not None:
|
|
192
|
+
params = {"cursor": str(cursor)}
|
|
193
|
+
|
|
194
|
+
result = self.request(method="tools/list", params=params)
|
|
195
|
+
tools = result.get("tools")
|
|
196
|
+
if not isinstance(tools, list):
|
|
197
|
+
raise McpProtocolError("MCP tools/list result missing tools list")
|
|
198
|
+
out: List[Dict[str, Any]] = []
|
|
199
|
+
for t in tools:
|
|
200
|
+
if isinstance(t, dict):
|
|
201
|
+
out.append(t)
|
|
202
|
+
return out
|
|
203
|
+
|
|
204
|
+
def call_tool(self, *, name: str, arguments: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
205
|
+
tool_name = str(name or "").strip()
|
|
206
|
+
if not tool_name:
|
|
207
|
+
raise ValueError("MCP tools/call requires a non-empty tool name")
|
|
208
|
+
args = dict(arguments or {})
|
|
209
|
+
|
|
210
|
+
result = self.request(method="tools/call", params={"name": tool_name, "arguments": args})
|
|
211
|
+
if not isinstance(result, dict):
|
|
212
|
+
raise McpProtocolError("MCP tools/call result must be an object")
|
|
213
|
+
return result
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
|
|
5
|
+
from .client import McpClient
|
|
6
|
+
from .stdio_client import McpStdioClient
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def create_mcp_client(*, config: Dict[str, Any], timeout_s: Optional[float] = 30.0) -> Any:
|
|
10
|
+
"""Create an MCP client from a persisted config entry.
|
|
11
|
+
|
|
12
|
+
Supported config shapes (backwards compatible):
|
|
13
|
+
- HTTP (Streamable HTTP):
|
|
14
|
+
{"url": "https://...", "headers": {...}, "transport": "streamable_http" (optional)}
|
|
15
|
+
- stdio:
|
|
16
|
+
{"transport": "stdio", "command": ["ssh", "host", "..."], "cwd": "...", "env": {...}}
|
|
17
|
+
"""
|
|
18
|
+
if not isinstance(config, dict):
|
|
19
|
+
raise ValueError("MCP client config must be a dict")
|
|
20
|
+
|
|
21
|
+
transport = str(config.get("transport") or "").strip().lower()
|
|
22
|
+
if not transport:
|
|
23
|
+
transport = "stdio" if "command" in config else "streamable_http"
|
|
24
|
+
|
|
25
|
+
if transport in ("stdio",):
|
|
26
|
+
command_raw = config.get("command")
|
|
27
|
+
if isinstance(command_raw, str):
|
|
28
|
+
# Allow power-users to store a single shell string (best-effort).
|
|
29
|
+
command = [command_raw]
|
|
30
|
+
elif isinstance(command_raw, list):
|
|
31
|
+
command = [str(c) for c in command_raw if str(c).strip()]
|
|
32
|
+
else:
|
|
33
|
+
command = []
|
|
34
|
+
if not command:
|
|
35
|
+
raise ValueError("stdio MCP config requires a non-empty 'command' list")
|
|
36
|
+
|
|
37
|
+
cwd = config.get("cwd")
|
|
38
|
+
cwd_s = str(cwd).strip() if isinstance(cwd, str) and cwd.strip() else None
|
|
39
|
+
|
|
40
|
+
env_raw = config.get("env")
|
|
41
|
+
if isinstance(env_raw, dict):
|
|
42
|
+
env = {
|
|
43
|
+
str(k): str(v)
|
|
44
|
+
for k, v in env_raw.items()
|
|
45
|
+
if isinstance(k, str) and str(k).strip() and v is not None
|
|
46
|
+
}
|
|
47
|
+
else:
|
|
48
|
+
env = None
|
|
49
|
+
|
|
50
|
+
return McpStdioClient(command=command, cwd=cwd_s, env=env, timeout_s=timeout_s)
|
|
51
|
+
|
|
52
|
+
url = config.get("url")
|
|
53
|
+
if not isinstance(url, str) or not url.strip():
|
|
54
|
+
raise ValueError("HTTP MCP config requires a non-empty 'url'")
|
|
55
|
+
|
|
56
|
+
headers_raw = config.get("headers")
|
|
57
|
+
if headers_raw is None:
|
|
58
|
+
headers = None
|
|
59
|
+
elif isinstance(headers_raw, dict):
|
|
60
|
+
headers = {str(k): str(v) for k, v in headers_raw.items() if isinstance(k, str) and str(k).strip()}
|
|
61
|
+
else:
|
|
62
|
+
headers = None
|
|
63
|
+
|
|
64
|
+
return McpClient(url=url.strip(), headers=headers, timeout_s=timeout_s)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional, Tuple
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
MCP_TOOL_PREFIX = "mcp::"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def namespaced_tool_name(*, server_id: str, tool_name: str) -> str:
|
|
10
|
+
sid = str(server_id or "").strip()
|
|
11
|
+
tn = str(tool_name or "").strip()
|
|
12
|
+
if not sid:
|
|
13
|
+
raise ValueError("server_id must be non-empty")
|
|
14
|
+
if not tn:
|
|
15
|
+
raise ValueError("tool_name must be non-empty")
|
|
16
|
+
return f"{MCP_TOOL_PREFIX}{sid}::{tn}"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parse_namespaced_tool_name(name: str) -> Optional[Tuple[str, str]]:
|
|
20
|
+
text = str(name or "").strip()
|
|
21
|
+
if not text.startswith(MCP_TOOL_PREFIX):
|
|
22
|
+
return None
|
|
23
|
+
rest = text[len(MCP_TOOL_PREFIX) :]
|
|
24
|
+
sid, sep, tn = rest.partition("::")
|
|
25
|
+
if sep != "::" or not sid or not tn:
|
|
26
|
+
return None
|
|
27
|
+
return sid, tn
|
|
28
|
+
|