11# TODO: Remove when Python 3.9 support is dropped
22from __future__ import annotations
33
4+ import asyncio
5+ import base64
46import fnmatch
7+ import json
58import os
9+ import threading
610import 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
916from stackone_ai .constants import OAS_DIR
1017from stackone_ai .models import (
18+ ExecuteConfig ,
19+ ParameterLocation ,
1120 StackOneTool ,
21+ ToolParameters ,
1222 Tools ,
1323)
1424from 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 (slots = True )
45+ class _McpToolDefinition :
46+ name : str
47+ description : str | None
48+ input_schema : dict [str , Any ]
49+
1650
1751class 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+
35229class 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