Skip to content

Commit d72ca80

Browse files
authored
feat: Add MCP-backed dynamic tool fetching to Python SDK (#39)
* feat: implement MCP-backed fetch_tools() with dynamic tool discovery Add MCP (Model Context Protocol) integration to enable dynamic tool fetching at runtime, matching the Node SDK functionality. This allows users to pull the latest tool definitions from the StackOne API without SDK updates. Key Features: - Dynamic tool discovery via MCP server endpoint - _StackOneRpcTool class for RPC-backed execution at /actions/rpc - Account filtering with set_accounts() and account_ids parameter - Provider filtering (case-insensitive, prefix-based) - Action filtering with glob pattern support - Thread-safe async execution wrapper (_run_async) - Proper authentication with Basic Auth headers Implementation Details: - MCP client integration with streamablehttp transport - Tool catalog fetching with pagination support (cursor-based) - Schema normalization for proper nullable field handling - RPC payload construction with body/headers/path/query support - Account scoping via x-account-id header - User-Agent tracking for SDK version telemetry Dependencies: - Requires optional 'mcp' extra: pip install 'stackone-ai[mcp]' - Raises ToolsetConfigError if MCP dependencies not available The implementation maintains API parity with the Node SDK's stackone.mcp-fetch functionality while following Python idioms. * test: add comprehensive test suite for MCP fetch_tools functionality Add 12 new tests covering MCP-backed tool fetching to match Node SDK test coverage in stackone.mcp-fetch.spec.ts. Tests are organized into logical groups for account and provider/action filtering scenarios. Test Coverage: Account Filtering (TestAccountFiltering): - fetch_tools_with_single_account_id: Single account filtering - fetch_tools_with_multiple_account_ids: Multiple account filtering - fetch_tools_with_set_accounts: Using set_accounts() method - fetch_tools_account_ids_override_set_accounts: Parameter precedence - fetch_tools_with_constructor_account_id: Constructor-based account - fetch_tools_with_empty_account_ids: Empty list handling Provider/Action Filtering (TestProviderAndActionFiltering): - fetch_tools_with_provider_filter: Single provider filtering - fetch_tools_with_multiple_providers: Multiple provider filtering - fetch_tools_with_action_glob_pattern: Glob pattern matching - fetch_tools_with_exact_action_match: Exact action name matching - fetch_tools_with_provider_and_action_filters: Combined filtering - fetch_tools_with_exclusion_pattern: Negative glob patterns Removed: - Deleted old fetch_tools tests that used non-MCP approach - These tests were testing the old implementation that loaded from OpenAPI specs instead of the MCP server The test suite uses comprehensive mocking of MCP client components: - ClientSession for MCP protocol communication - streamablehttp_client for HTTP transport - Tool list responses with pagination support - Proper async context manager handling All tests verify: - Correct tool count after filtering - Proper tool presence/absence - Account ID preservation in tool context - Filter precedence and interaction * docs: document MCP-backed dynamic tool discovery feature
1 parent a1c688b commit d72ca80

File tree

4 files changed

+537
-224
lines changed

4 files changed

+537
-224
lines changed

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ StackOne AI provides a unified interface for accessing various SaaS tools throug
1414
- Glob pattern filtering with patterns like `"hris_*"` and exclusions `"!hris_delete_*"`
1515
- Provider and action filtering with `fetch_tools()`
1616
- Multi-account support
17+
- Dynamic MCP-backed discovery via `fetch_tools()` so you can pull the latest tools at runtime (accounts, providers, or globbed actions)
1718
- **Meta Tools** (Beta): Dynamic tool discovery and execution based on natural language queries using hybrid BM25 + TF-IDF search
1819
- Integration with popular AI frameworks:
1920
- OpenAI Functions
@@ -105,6 +106,8 @@ tools = toolset.get_tools(["hris_*", "!hris_delete_*"])
105106

106107
The `fetch_tools()` method provides advanced filtering by providers, actions, and account IDs:
107108

109+
> `fetch_tools()` uses the StackOne MCP server under the hood. Install the optional extra (`pip install 'stackone-ai[mcp]'`) on Python 3.10+ to enable dynamic discovery.
110+
108111
```python
109112
from stackone_ai import StackOneToolSet
110113

stackone_ai/toolset.py

Lines changed: 262 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,52 @@
11
# TODO: Remove when Python 3.9 support is dropped
22
from __future__ import annotations
33

4+
import asyncio
5+
import base64
46
import fnmatch
7+
import json
58
import os
9+
import threading
610
import warnings
7-
from typing import Any
11+
from collections.abc import Coroutine
12+
from dataclasses import dataclass
13+
from importlib import metadata
14+
from typing import Any, TypeVar
815

916
from stackone_ai.constants import OAS_DIR
1017
from stackone_ai.models import (
18+
ExecuteConfig,
19+
ParameterLocation,
1120
StackOneTool,
21+
ToolParameters,
1222
Tools,
1323
)
1424
from stackone_ai.specs.parser import OpenAPIParser
1525

26+
try:
27+
_SDK_VERSION = metadata.version("stackone-ai")
28+
except metadata.PackageNotFoundError: # pragma: no cover - best-effort fallback when running from source
29+
_SDK_VERSION = "dev"
30+
31+
DEFAULT_BASE_URL = "https://api.stackone.com"
32+
_RPC_PARAMETER_LOCATIONS = {
33+
"action": ParameterLocation.BODY,
34+
"body": ParameterLocation.BODY,
35+
"headers": ParameterLocation.BODY,
36+
"path": ParameterLocation.BODY,
37+
"query": ParameterLocation.BODY,
38+
}
39+
_USER_AGENT = f"stackone-ai-python/{_SDK_VERSION}"
40+
41+
T = TypeVar("T")
42+
43+
44+
@dataclass
45+
class _McpToolDefinition:
46+
name: str
47+
description: str | None
48+
input_schema: dict[str, Any]
49+
1650

1751
class ToolsetError(Exception):
1852
"""Base exception for toolset errors"""
@@ -32,6 +66,166 @@ class ToolsetLoadError(ToolsetError):
3266
pass
3367

3468

69+
def _run_async(awaitable: Coroutine[Any, Any, T]) -> T:
70+
"""Run a coroutine, even when called from an existing event loop."""
71+
72+
try:
73+
asyncio.get_running_loop()
74+
except RuntimeError:
75+
return asyncio.run(awaitable)
76+
77+
result: dict[str, T] = {}
78+
error: dict[str, BaseException] = {}
79+
80+
def runner() -> None:
81+
try:
82+
result["value"] = asyncio.run(awaitable)
83+
except BaseException as exc: # pragma: no cover - surfaced in caller context
84+
error["error"] = exc
85+
86+
thread = threading.Thread(target=runner, daemon=True)
87+
thread.start()
88+
thread.join()
89+
90+
if "error" in error:
91+
raise error["error"]
92+
93+
return result["value"]
94+
95+
96+
def _build_auth_header(api_key: str) -> str:
97+
token = base64.b64encode(f"{api_key}:".encode()).decode()
98+
return f"Basic {token}"
99+
100+
101+
def _fetch_mcp_tools(endpoint: str, headers: dict[str, str]) -> list[_McpToolDefinition]:
102+
try:
103+
from mcp import types as mcp_types
104+
from mcp.client.session import ClientSession
105+
from mcp.client.streamable_http import streamablehttp_client
106+
except ImportError as exc: # pragma: no cover - depends on optional extra
107+
raise ToolsetConfigError(
108+
"MCP dependencies are required for fetch_tools. Install with 'pip install \"stackone-ai[mcp]\"'."
109+
) from exc
110+
111+
async def _list() -> list[_McpToolDefinition]:
112+
async with streamablehttp_client(endpoint, headers=headers) as (read_stream, write_stream, _):
113+
session = ClientSession(
114+
read_stream,
115+
write_stream,
116+
client_info=mcp_types.Implementation(name="stackone-ai-python", version=_SDK_VERSION),
117+
)
118+
async with session:
119+
await session.initialize()
120+
cursor: str | None = None
121+
collected: list[_McpToolDefinition] = []
122+
while True:
123+
result = await session.list_tools(cursor)
124+
for tool in result.tools:
125+
input_schema = tool.inputSchema or {}
126+
collected.append(
127+
_McpToolDefinition(
128+
name=tool.name,
129+
description=tool.description,
130+
input_schema=dict(input_schema),
131+
)
132+
)
133+
cursor = result.nextCursor
134+
if cursor is None:
135+
break
136+
return collected
137+
138+
return _run_async(_list())
139+
140+
141+
class _StackOneRpcTool(StackOneTool):
142+
"""RPC-backed tool wired to the StackOne actions RPC endpoint."""
143+
144+
def __init__(
145+
self,
146+
*,
147+
name: str,
148+
description: str,
149+
parameters: ToolParameters,
150+
api_key: str,
151+
base_url: str,
152+
account_id: str | None,
153+
) -> None:
154+
execute_config = ExecuteConfig(
155+
method="POST",
156+
url=f"{base_url.rstrip('/')}/actions/rpc",
157+
name=name,
158+
headers={},
159+
body_type="json",
160+
parameter_locations=dict(_RPC_PARAMETER_LOCATIONS),
161+
)
162+
super().__init__(
163+
description=description,
164+
parameters=parameters,
165+
_execute_config=execute_config,
166+
_api_key=api_key,
167+
_account_id=account_id,
168+
)
169+
170+
def execute(
171+
self, arguments: str | dict[str, Any] | None = None, *, options: dict[str, Any] | None = None
172+
) -> dict[str, Any]:
173+
parsed_arguments = self._parse_arguments(arguments)
174+
175+
body_payload = self._extract_record(parsed_arguments.pop("body", None))
176+
headers_payload = self._extract_record(parsed_arguments.pop("headers", None))
177+
path_payload = self._extract_record(parsed_arguments.pop("path", None))
178+
query_payload = self._extract_record(parsed_arguments.pop("query", None))
179+
180+
rpc_body: dict[str, Any] = dict(body_payload or {})
181+
for key, value in parsed_arguments.items():
182+
rpc_body[key] = value
183+
184+
payload: dict[str, Any] = {
185+
"action": self.name,
186+
"body": rpc_body,
187+
"headers": self._build_action_headers(headers_payload),
188+
}
189+
if path_payload:
190+
payload["path"] = path_payload
191+
if query_payload:
192+
payload["query"] = query_payload
193+
194+
return super().execute(payload, options=options)
195+
196+
def _parse_arguments(self, arguments: str | dict[str, Any] | None) -> dict[str, Any]:
197+
if arguments is None:
198+
return {}
199+
if isinstance(arguments, str):
200+
parsed = json.loads(arguments)
201+
else:
202+
parsed = arguments
203+
if not isinstance(parsed, dict):
204+
raise ValueError("Tool arguments must be a JSON object")
205+
return dict(parsed)
206+
207+
@staticmethod
208+
def _extract_record(value: Any) -> dict[str, Any] | None:
209+
if isinstance(value, dict):
210+
return dict(value)
211+
return None
212+
213+
def _build_action_headers(self, additional_headers: dict[str, Any] | None) -> dict[str, str]:
214+
headers: dict[str, str] = {}
215+
account_id = self.get_account_id()
216+
if account_id:
217+
headers["x-account-id"] = account_id
218+
219+
if additional_headers:
220+
for key, value in additional_headers.items():
221+
if value is None:
222+
continue
223+
headers[str(key)] = str(value)
224+
225+
headers.pop("Authorization", None)
226+
return headers
227+
228+
35229
class StackOneToolSet:
36230
"""Main class for accessing StackOne tools"""
37231

@@ -59,7 +253,7 @@ def __init__(
59253
)
60254
self.api_key: str = api_key_value
61255
self.account_id = account_id
62-
self.base_url = base_url
256+
self.base_url = base_url or DEFAULT_BASE_URL
63257
self._account_ids: list[str] = []
64258

65259
def _parse_parameters(self, parameters: list[dict[str, Any]]) -> dict[str, dict[str, str]]:
@@ -194,34 +388,83 @@ def fetch_tools(
194388
tools = toolset.fetch_tools()
195389
"""
196390
try:
197-
# Use account IDs from options, or fall back to instance state
198391
effective_account_ids = account_ids or self._account_ids
392+
if not effective_account_ids and self.account_id:
393+
effective_account_ids = [self.account_id]
199394

200-
all_tools: list[StackOneTool] = []
201-
202-
# Load tools for each account ID or once if no account filtering
203395
if effective_account_ids:
204-
for acc_id in effective_account_ids:
205-
tools = self.get_tools(account_id=acc_id)
206-
all_tools.extend(tools.to_list())
396+
account_scope: list[str | None] = list(dict.fromkeys(effective_account_ids))
207397
else:
208-
tools = self.get_tools()
209-
all_tools.extend(tools.to_list())
398+
account_scope = [None]
399+
400+
endpoint = f"{self.base_url.rstrip('/')}/mcp"
401+
all_tools: list[StackOneTool] = []
402+
403+
for account in account_scope:
404+
headers = self._build_mcp_headers(account)
405+
catalog = _fetch_mcp_tools(endpoint, headers)
406+
for tool_def in catalog:
407+
all_tools.append(self._create_rpc_tool(tool_def, account))
210408

211-
# Apply provider filtering
212409
if providers:
213-
all_tools = [t for t in all_tools if self._filter_by_provider(t.name, providers)]
410+
all_tools = [tool for tool in all_tools if self._filter_by_provider(tool.name, providers)]
214411

215-
# Apply action filtering
216412
if actions:
217-
all_tools = [t for t in all_tools if self._filter_by_action(t.name, actions)]
413+
all_tools = [tool for tool in all_tools if self._filter_by_action(tool.name, actions)]
218414

219415
return Tools(all_tools)
220416

221-
except Exception as e:
222-
if isinstance(e, ToolsetError):
223-
raise
224-
raise ToolsetLoadError(f"Error fetching tools: {e}") from e
417+
except ToolsetError:
418+
raise
419+
except Exception as exc: # pragma: no cover - unexpected runtime errors
420+
raise ToolsetLoadError(f"Error fetching tools: {exc}") from exc
421+
422+
def _build_mcp_headers(self, account_id: str | None) -> dict[str, str]:
423+
headers = {
424+
"Authorization": _build_auth_header(self.api_key),
425+
"User-Agent": _USER_AGENT,
426+
}
427+
if account_id:
428+
headers["x-account-id"] = account_id
429+
return headers
430+
431+
def _create_rpc_tool(self, tool_def: _McpToolDefinition, account_id: str | None) -> StackOneTool:
432+
schema = tool_def.input_schema or {}
433+
parameters = ToolParameters(
434+
type=str(schema.get("type") or "object"),
435+
properties=self._normalize_schema_properties(schema),
436+
)
437+
return _StackOneRpcTool(
438+
name=tool_def.name,
439+
description=tool_def.description or "",
440+
parameters=parameters,
441+
api_key=self.api_key,
442+
base_url=self.base_url,
443+
account_id=account_id,
444+
)
445+
446+
def _normalize_schema_properties(self, schema: dict[str, Any]) -> dict[str, Any]:
447+
properties = schema.get("properties")
448+
if not isinstance(properties, dict):
449+
return {}
450+
451+
required_fields = {str(name) for name in schema.get("required", [])}
452+
normalized: dict[str, Any] = {}
453+
454+
for name, details in properties.items():
455+
if isinstance(details, dict):
456+
prop = dict(details)
457+
else:
458+
prop = {"description": str(details)}
459+
460+
if name in required_fields:
461+
prop.setdefault("nullable", False)
462+
else:
463+
prop.setdefault("nullable", True)
464+
465+
normalized[str(name)] = prop
466+
467+
return normalized
225468

226469
def get_tool(self, name: str, *, account_id: str | None = None) -> StackOneTool | None:
227470
"""Get a specific tool by name

0 commit comments

Comments
 (0)