universal-mcp 0.1.23rc1__py3-none-any.whl → 0.1.23rc2__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.
@@ -374,113 +374,6 @@ class APIApplication(BaseApplication):
374
374
  logger.debug(f"PATCH request successful with status code: {response.status_code}")
375
375
  return response
376
376
 
377
- # New convenience methods that handle responses automatically with enhanced error handling
378
- def _get_json(self, url: str, params: dict[str, Any] | None = None) -> dict[str, Any] | str:
379
- """
380
- Make a GET request and automatically handle the response with enhanced error handling.
381
-
382
- Args:
383
- url: The URL to send the request to
384
- params: Optional query parameters
385
-
386
- Returns:
387
- dict[str, Any] | str: Parsed JSON response if available, otherwise success message
388
-
389
- Raises:
390
- httpx.HTTPStatusError: If the request fails with detailed error information including response body
391
- """
392
- response = self._get(url, params)
393
- return self._handle_response(response)
394
-
395
- def _post_json(
396
- self,
397
- url: str,
398
- data: Any,
399
- params: dict[str, Any] | None = None,
400
- content_type: str = "application/json",
401
- files: dict[str, Any] | None = None,
402
- ) -> dict[str, Any] | str:
403
- """
404
- Make a POST request and automatically handle the response with enhanced error handling.
405
-
406
- Args:
407
- url: The URL to send the request to
408
- data: The data to send
409
- params: Optional query parameters
410
- content_type: The Content-Type of the request body
411
- files: Optional dictionary of files to upload
412
-
413
- Returns:
414
- dict[str, Any] | str: Parsed JSON response if available, otherwise success message
415
-
416
- Raises:
417
- httpx.HTTPStatusError: If the request fails with detailed error information including response body
418
- """
419
- response = self._post(url, data, params, content_type, files)
420
- return self._handle_response(response)
421
-
422
- def _put_json(
423
- self,
424
- url: str,
425
- data: Any,
426
- params: dict[str, Any] | None = None,
427
- content_type: str = "application/json",
428
- files: dict[str, Any] | None = None,
429
- ) -> dict[str, Any] | str:
430
- """
431
- Make a PUT request and automatically handle the response with enhanced error handling.
432
-
433
- Args:
434
- url: The URL to send the request to
435
- data: The data to send
436
- params: Optional query parameters
437
- content_type: The Content-Type of the request body
438
- files: Optional dictionary of files to upload
439
-
440
- Returns:
441
- dict[str, Any] | str: Parsed JSON response if available, otherwise success message
442
-
443
- Raises:
444
- httpx.HTTPStatusError: If the request fails with detailed error information including response body
445
- """
446
- response = self._put(url, data, params, content_type, files)
447
- return self._handle_response(response)
448
-
449
- def _delete_json(self, url: str, params: dict[str, Any] | None = None) -> dict[str, Any] | str:
450
- """
451
- Make a DELETE request and automatically handle the response with enhanced error handling.
452
-
453
- Args:
454
- url: The URL to send the request to
455
- params: Optional query parameters
456
-
457
- Returns:
458
- dict[str, Any] | str: Parsed JSON response if available, otherwise success message
459
-
460
- Raises:
461
- httpx.HTTPStatusError: If the request fails with detailed error information including response body
462
- """
463
- response = self._delete(url, params)
464
- return self._handle_response(response)
465
-
466
- def _patch_json(self, url: str, data: dict[str, Any], params: dict[str, Any] | None = None) -> dict[str, Any] | str:
467
- """
468
- Make a PATCH request and automatically handle the response with enhanced error handling.
469
-
470
- Args:
471
- url: The URL to send the request to
472
- data: The data to send in the request body
473
- params: Optional query parameters
474
-
475
- Returns:
476
- dict[str, Any] | str: Parsed JSON response if available, otherwise success message
477
-
478
- Raises:
479
- httpx.HTTPStatusError: If the request fails with detailed error information including response body
480
- """
481
- response = self._patch(url, data, params)
482
- return self._handle_response(response)
483
-
484
377
 
485
378
  class GraphQLApplication(BaseApplication):
486
379
  """
universal_mcp/cli.py CHANGED
@@ -270,6 +270,7 @@ def preprocess(
270
270
  def split_api(
271
271
  input_app_file: Path = typer.Argument(..., help="Path to the generated app.py file to split"),
272
272
  output_dir: Path = typer.Option(..., "--output-dir", "-o", help="Directory to save the split files"),
273
+ package_name: str = typer.Option(None, "--package-name", "-p", help="Package name for absolute imports (e.g., 'hubspot')"),
273
274
  ):
274
275
  """Splits a single generated API client file into multiple files based on path groups."""
275
276
  from universal_mcp.utils.openapi.api_splitter import split_generated_app_file
@@ -286,7 +287,7 @@ def split_api(
286
287
  raise typer.Exit(1)
287
288
 
288
289
  try:
289
- split_generated_app_file(input_app_file, output_dir)
290
+ split_generated_app_file(input_app_file, output_dir, package_name)
290
291
  console.print(f"[green]Successfully split {input_app_file} into {output_dir}[/green]")
291
292
  except Exception as e:
292
293
  console.print(f"[red]Error splitting API client: {e}[/red]")
@@ -0,0 +1,30 @@
1
+ import asyncio
2
+ import os
3
+ import sys
4
+
5
+ from loguru import logger
6
+ from pydantic import ValidationError
7
+
8
+ from universal_mcp.client.agent import ChatSession
9
+ from universal_mcp.client.client import MultiClientServer
10
+ from universal_mcp.config import ClientConfig
11
+
12
+
13
+ async def main() -> None:
14
+ """Initialize and run the chat session."""
15
+ # Load settings and config using Pydantic BaseSettings
16
+
17
+ config_path = os.getenv("MCP_CONFIG_PATH", "servers.json")
18
+ try:
19
+ app_config = ClientConfig.load_json_config(config_path)
20
+ except (FileNotFoundError, ValidationError) as e:
21
+ logger.error(f"Error loading config: {e}")
22
+ sys.exit(1)
23
+
24
+ async with MultiClientServer(app_config.mcpServers) as mcp_server:
25
+ chat_session = ChatSession(mcp_server, app_config.llm)
26
+ await chat_session.interactive_loop()
27
+
28
+
29
+ if __name__ == "__main__":
30
+ asyncio.run(main())
@@ -0,0 +1,96 @@
1
+ import json
2
+
3
+ from loguru import logger
4
+ from mcp.server import Server as MCPServer
5
+ from openai import AsyncOpenAI
6
+
7
+ from universal_mcp.config import LLMConfig
8
+
9
+
10
+ class ChatSession:
11
+ """Orchestrates the interaction between user, LLM, and tools."""
12
+
13
+ def __init__(self, mcp_server: MCPServer, llm: LLMConfig | None) -> None:
14
+ self.mcp_server: MCPServer = mcp_server
15
+ self.llm: AsyncOpenAI | None = AsyncOpenAI(api_key=llm.api_key, base_url=llm.base_url) if llm else None
16
+ self.model = llm.model if llm else None
17
+
18
+ async def run(self, messages, tools) -> None:
19
+ """Run the chat session."""
20
+ llm_response = await self.llm.chat.completions.create(
21
+ model=self.model,
22
+ messages=messages,
23
+ tools=tools,
24
+ tool_choice="auto",
25
+ )
26
+
27
+ tool_calls = llm_response.choices[0].message.tool_calls
28
+ if tool_calls:
29
+ for tool_call in tool_calls:
30
+ result = await self.mcp_server.call_tool(
31
+ tool_name=tool_call.function.name,
32
+ arguments=json.loads(tool_call.function.arguments) if tool_call.function.arguments else {},
33
+ )
34
+ result_content = [rc.text for rc in result.content] if result.content else "No result"
35
+ messages.append(
36
+ {
37
+ "tool_call_id": tool_call.id,
38
+ "role": "tool",
39
+ "name": tool_call.function.name,
40
+ "content": result_content,
41
+ }
42
+ )
43
+ else:
44
+ messages.append(llm_response.choices[0].message)
45
+ return messages
46
+
47
+ async def interactive_loop(self) -> None:
48
+ """Main chat session handler."""
49
+ all_openai_tools = await self.mcp_server.list_tools(format="openai")
50
+ system_message = "You are a helpful assistant"
51
+ messages = [{"role": "system", "content": system_message}]
52
+
53
+ print("\n🎯 Interactive MCP Client")
54
+ print("Commands:")
55
+ print(" list - List available tools")
56
+ print(" call <tool_name> [args] - Call a tool")
57
+ print(" quit - Exit the client")
58
+ print()
59
+ while True:
60
+ try:
61
+ user_input = input("You: ").strip()
62
+ if user_input.lower() in {"quit", "exit"}:
63
+ logger.info("\nExiting...")
64
+ break
65
+ elif user_input.lower() == "list":
66
+ tools = await self.mcp_server.list_tools()
67
+ print("\nAvailable tools:")
68
+ for tool in tools:
69
+ print(f" {tool.name}")
70
+ continue
71
+ elif user_input.startswith("call "):
72
+ parts = user_input.split(maxsplit=2)
73
+ tool_name = parts[1] if len(parts) > 1 else ""
74
+
75
+ if not tool_name:
76
+ print("❌ Please specify a tool name")
77
+ continue
78
+
79
+ # Parse arguments (simple JSON-like format)
80
+ arguments = {}
81
+ if len(parts) > 2:
82
+ try:
83
+ arguments = json.loads(parts[2])
84
+ except json.JSONDecodeError:
85
+ print("❌ Invalid arguments format (expected JSON)")
86
+ continue
87
+ await self.mcp_server.call_tool(tool_name, arguments)
88
+
89
+ messages.append({"role": "user", "content": user_input})
90
+
91
+ messages = await self.run(messages, all_openai_tools)
92
+ print("\nAssistant: ", messages[-1]["content"])
93
+
94
+ except KeyboardInterrupt:
95
+ print("\nExiting...")
96
+ break
@@ -0,0 +1,198 @@
1
+ import os
2
+ import webbrowser
3
+ from contextlib import AsyncExitStack
4
+ from typing import Any, Literal
5
+
6
+ from loguru import logger
7
+ from mcp import ClientSession, StdioServerParameters
8
+ from mcp.client.auth import OAuthClientProvider
9
+ from mcp.client.sse import sse_client
10
+ from mcp.client.stdio import stdio_client
11
+ from mcp.client.streamable_http import streamablehttp_client
12
+ from mcp.server import Server
13
+ from mcp.shared.auth import OAuthClientMetadata
14
+ from mcp.types import (
15
+ CallToolResult as MCPCallToolResult,
16
+ )
17
+ from mcp.types import (
18
+ Tool as MCPTool,
19
+ )
20
+ from openai.types.chat import ChatCompletionToolParam
21
+
22
+ from universal_mcp.client.oauth import CallbackServer
23
+ from universal_mcp.client.token_store import TokenStore
24
+ from universal_mcp.config import ClientTransportConfig
25
+ from universal_mcp.stores.store import KeyringStore
26
+ from universal_mcp.tools.adapters import transform_mcp_tool_to_openai_tool
27
+
28
+
29
+ class MCPClient:
30
+ """Manages MCP server connections and tool execution."""
31
+
32
+ def __init__(self, name: str, config: ClientTransportConfig) -> None:
33
+ self.name: str = name
34
+ self.config: ClientTransportConfig = config
35
+ self.session: ClientSession | None = None
36
+ self.server_url: str = config.url
37
+
38
+ # Set up callback server
39
+ self.callback_server = CallbackServer(port=3000)
40
+ self.callback_server.start()
41
+
42
+ # Create OAuth authentication handler using the new interface
43
+ if self.server_url and not self.config.headers:
44
+ self.store = KeyringStore(self.name)
45
+ self.auth = OAuthClientProvider(
46
+ server_url="/".join(self.server_url.split("/")[:-1]),
47
+ client_metadata=OAuthClientMetadata.model_validate(self.client_metadata_dict),
48
+ storage=TokenStore(self.store),
49
+ redirect_handler=self._default_redirect_handler,
50
+ callback_handler=self._callback_handler,
51
+ )
52
+ else:
53
+ self.auth = None
54
+
55
+ async def _callback_handler(self) -> tuple[str, str | None]:
56
+ """Wait for OAuth callback and return auth code and state."""
57
+ print("⏳ Waiting for authorization callback...")
58
+ try:
59
+ auth_code = self.callback_server.wait_for_callback(timeout=300)
60
+ return auth_code, self.callback_server.get_state()
61
+ finally:
62
+ self.callback_server.stop()
63
+
64
+ @property
65
+ def client_metadata_dict(self) -> dict[str, Any]:
66
+ return {
67
+ "client_name": "Simple Auth Client",
68
+ "redirect_uris": ["http://localhost:3000/callback"],
69
+ "grant_types": ["authorization_code", "refresh_token"],
70
+ "response_types": ["code"],
71
+ "token_endpoint_auth_method": "client_secret_post",
72
+ }
73
+
74
+ async def _default_redirect_handler(self, authorization_url: str) -> None:
75
+ """Default redirect handler that opens the URL in a browser."""
76
+ print(f"Opening browser for authorization: {authorization_url}")
77
+ webbrowser.open(authorization_url)
78
+
79
+ async def initialize(self, exit_stack: AsyncExitStack):
80
+ """Initialize the server connection."""
81
+ transport = self.config.transport
82
+ try:
83
+ if transport == "stdio":
84
+ command = self.config["command"]
85
+ if command is None:
86
+ raise ValueError("The command must be a valid string and cannot be None.")
87
+
88
+ server_params = StdioServerParameters(
89
+ command=command,
90
+ args=self.config["args"],
91
+ env={**os.environ, **self.config["env"]} if self.config.get("env") else None,
92
+ )
93
+ stdio_transport = await exit_stack.enter_async_context(stdio_client(server_params))
94
+ read, write = stdio_transport
95
+ session = await exit_stack.enter_async_context(ClientSession(read, write))
96
+ await session.initialize()
97
+ self.session = session
98
+ elif transport == "streamable_http":
99
+ url = self.config.get("url")
100
+ headers = self.config.get("headers", {})
101
+ if not url:
102
+ raise ValueError("'url' must be provided for streamable_http transport.")
103
+ streamable_http_transport = await exit_stack.enter_async_context(
104
+ streamablehttp_client(url=url, headers=headers, auth=self.auth)
105
+ )
106
+ read, write, _ = streamable_http_transport
107
+ session = await exit_stack.enter_async_context(ClientSession(read, write))
108
+ await session.initialize()
109
+ self.session = session
110
+ elif transport == "sse":
111
+ url = self.config.url
112
+ headers = self.config.headers
113
+ if not url:
114
+ raise ValueError("'url' must be provided for sse transport.")
115
+ sse_transport = await exit_stack.enter_async_context(
116
+ sse_client(url=url, headers=headers, auth=self.auth)
117
+ )
118
+ read, write = sse_transport
119
+ session = await exit_stack.enter_async_context(ClientSession(read, write))
120
+ await session.initialize()
121
+ self.session = session
122
+ else:
123
+ raise ValueError(f"Unknown transport: {transport}")
124
+ except Exception as e:
125
+ logger.error(f"Error initializing server {self.name}: {e}")
126
+ raise
127
+
128
+ async def list_tools(self) -> list[MCPTool]:
129
+ """List available tools from the server."""
130
+ if self.session:
131
+ tools = await self.session.list_tools()
132
+ return list(tools.tools)
133
+ return []
134
+
135
+ async def call_tool(self, tool_name: str, arguments: dict[str, Any]) -> MCPCallToolResult:
136
+ """Call a tool on the server."""
137
+ if self.session:
138
+ return await self.session.call_tool(tool_name, arguments)
139
+ return MCPCallToolResult(
140
+ content=[],
141
+ isError=True,
142
+ )
143
+
144
+
145
+ class MultiClientServer(Server):
146
+ """
147
+ Manages multiple MCP servers and maintains a mapping from tool name to the server that provides it.
148
+ """
149
+
150
+ def __init__(self, clients: dict[str, ClientTransportConfig]):
151
+ self.clients: list[MCPClient] = [MCPClient(name, config) for name, config in clients.items()]
152
+ self.tool_to_client: dict[str, MCPClient] = {}
153
+ self._mcp_tools: list[MCPTool] = []
154
+ self._exit_stack: AsyncExitStack = AsyncExitStack()
155
+
156
+ async def __aenter__(self):
157
+ """Initialize the server connection."""
158
+ for client in self.clients:
159
+ await client.initialize(self._exit_stack)
160
+ await self._populate_tool_mapping()
161
+ return self
162
+
163
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
164
+ """Clean up the server connection."""
165
+ self.clients.clear()
166
+ self.tool_to_client.clear()
167
+ self._mcp_tools.clear()
168
+ await self._exit_stack.aclose()
169
+
170
+ async def _populate_tool_mapping(self):
171
+ """Populate the mapping from tool name to server."""
172
+ self.tool_to_client.clear()
173
+ self._mcp_tools.clear()
174
+ for client in self.clients:
175
+ try:
176
+ tools = await client.list_tools()
177
+ for tool in tools:
178
+ self._mcp_tools.append(tool)
179
+ tool_name = tool.name
180
+ logger.info(f"Found tool: {tool_name} from client: {client.name}")
181
+ if tool_name:
182
+ self.tool_to_client[tool_name] = client
183
+ except Exception as e:
184
+ logger.warning(f"Failed to list tools for client {client.name}: {e}")
185
+
186
+ async def list_tools(self, format: Literal["mcp", "openai"] = "mcp") -> list[MCPTool | ChatCompletionToolParam]:
187
+ """List available tools from all servers."""
188
+ if format == "mcp":
189
+ return self._mcp_tools
190
+ elif format == "openai":
191
+ return [transform_mcp_tool_to_openai_tool(tool) for tool in self._mcp_tools]
192
+ else:
193
+ raise ValueError(f"Invalid format: {format}")
194
+
195
+ async def call_tool(self, tool_name: str, arguments: dict[str, Any]) -> MCPCallToolResult:
196
+ """Call a tool on the server."""
197
+ client = self.tool_to_client[tool_name]
198
+ return await client.call_tool(tool_name, arguments)
@@ -0,0 +1,114 @@
1
+ import threading
2
+ import time
3
+ from http.server import BaseHTTPRequestHandler, HTTPServer
4
+ from urllib.parse import parse_qs, urlparse
5
+
6
+ from universal_mcp.utils.singleton import Singleton
7
+
8
+
9
+ class CallbackHandler(BaseHTTPRequestHandler):
10
+ """Simple HTTP handler to capture OAuth callback."""
11
+
12
+ def __init__(self, request, client_address, server, callback_data):
13
+ """Initialize with callback data storage."""
14
+ self.callback_data = callback_data
15
+ super().__init__(request, client_address, server)
16
+
17
+ def do_GET(self):
18
+ """Handle GET request from OAuth redirect."""
19
+ parsed = urlparse(self.path)
20
+ query_params = parse_qs(parsed.query)
21
+
22
+ if "code" in query_params:
23
+ self.callback_data["authorization_code"] = query_params["code"][0]
24
+ self.callback_data["state"] = query_params.get("state", [None])[0]
25
+ self.send_response(200)
26
+ self.send_header("Content-type", "text/html")
27
+ self.end_headers()
28
+ self.wfile.write(b"""
29
+ <html>
30
+ <body>
31
+ <h1>Authorization Successful!</h1>
32
+ <p>You can close this window and return to the terminal.</p>
33
+ <script>setTimeout(() => window.close(), 2000);</script>
34
+ </body>
35
+ </html>
36
+ """)
37
+ elif "error" in query_params:
38
+ self.callback_data["error"] = query_params["error"][0]
39
+ self.send_response(400)
40
+ self.send_header("Content-type", "text/html")
41
+ self.end_headers()
42
+ self.wfile.write(
43
+ f"""
44
+ <html>
45
+ <body>
46
+ <h1>Authorization Failed</h1>
47
+ <p>Error: {query_params['error'][0]}</p>
48
+ <p>You can close this window and return to the terminal.</p>
49
+ </body>
50
+ </html>
51
+ """.encode()
52
+ )
53
+ else:
54
+ self.send_response(404)
55
+ self.end_headers()
56
+
57
+ def log_message(self, format, *args):
58
+ """Suppress default logging."""
59
+ pass
60
+
61
+
62
+ class CallbackServer(metaclass=Singleton):
63
+ """Simple server to handle OAuth callbacks."""
64
+
65
+ def __init__(self, port=3000):
66
+ self.port = port
67
+ self.server = None
68
+ self.thread = None
69
+ self.callback_data = {"authorization_code": None, "state": None, "error": None}
70
+ self._running = False
71
+
72
+ def _create_handler_with_data(self):
73
+ """Create a handler class with access to callback data."""
74
+ callback_data = self.callback_data
75
+
76
+ class DataCallbackHandler(CallbackHandler):
77
+ def __init__(self, request, client_address, server):
78
+ super().__init__(request, client_address, server, callback_data)
79
+
80
+ return DataCallbackHandler
81
+
82
+ def start(self):
83
+ """Start the callback server in a background thread."""
84
+ if self._running:
85
+ return
86
+ handler_class = self._create_handler_with_data()
87
+ self.server = HTTPServer(("localhost", self.port), handler_class)
88
+ self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
89
+ self.thread.start()
90
+ print(f"🖥️ Started callback server on http://localhost:{self.port}")
91
+ self._running = True
92
+
93
+ def stop(self):
94
+ """Stop the callback server."""
95
+ if self.server:
96
+ self.server.shutdown()
97
+ self.server.server_close()
98
+ if self.thread:
99
+ self.thread.join(timeout=1)
100
+
101
+ def wait_for_callback(self, timeout=300):
102
+ """Wait for OAuth callback with timeout."""
103
+ start_time = time.time()
104
+ while time.time() - start_time < timeout:
105
+ if self.callback_data["authorization_code"]:
106
+ return self.callback_data["authorization_code"]
107
+ elif self.callback_data["error"]:
108
+ raise Exception(f"OAuth error: {self.callback_data['error']}")
109
+ time.sleep(0.1)
110
+ raise Exception("Timeout waiting for OAuth callback")
111
+
112
+ def get_state(self):
113
+ """Get the received state parameter."""
114
+ return self.callback_data["state"]
@@ -0,0 +1,32 @@
1
+ from mcp.client.auth import TokenStorage as MCPTokenStorage
2
+ from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
3
+
4
+ from universal_mcp.exceptions import KeyNotFoundError
5
+ from universal_mcp.stores.store import KeyringStore
6
+
7
+
8
+ class TokenStore(MCPTokenStorage):
9
+ """Simple in-memory token storage implementation."""
10
+
11
+ def __init__(self, store: KeyringStore):
12
+ self.store = store
13
+ self._tokens: OAuthToken | None = None
14
+ self._client_info: OAuthClientInformationFull | None = None
15
+
16
+ async def get_tokens(self) -> OAuthToken | None:
17
+ try:
18
+ return OAuthToken.model_validate_json(self.store.get("tokens"))
19
+ except KeyNotFoundError:
20
+ return None
21
+
22
+ async def set_tokens(self, tokens: OAuthToken) -> None:
23
+ self.store.set("tokens", tokens.model_dump_json())
24
+
25
+ async def get_client_info(self) -> OAuthClientInformationFull | None:
26
+ try:
27
+ return OAuthClientInformationFull.model_validate_json(self.store.get("client_info"))
28
+ except KeyNotFoundError:
29
+ return None
30
+
31
+ async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
32
+ self.store.set("client_info", client_info.model_dump_json())
universal_mcp/config.py CHANGED
@@ -1,7 +1,8 @@
1
+ import json
1
2
  from pathlib import Path
2
- from typing import Any, Literal
3
+ from typing import Any, Literal, Self
3
4
 
4
- from pydantic import BaseModel, Field, SecretStr, field_validator
5
+ from pydantic import BaseModel, Field, SecretStr, field_validator, model_validator
5
6
  from pydantic_settings import BaseSettings, SettingsConfigDict
6
7
 
7
8
 
@@ -19,7 +20,7 @@ class IntegrationConfig(BaseModel):
19
20
  """Configuration for API integrations."""
20
21
 
21
22
  name: str = Field(..., description="Name of the integration")
22
- type: Literal["api_key", "oauth", "agentr", "oauth2"] = Field(
23
+ type: Literal["api_key", "oauth", "agentr", "oauth2", "basic_auth"] = Field(
23
24
  default="api_key", description="Type of authentication to use"
24
25
  )
25
26
  credentials: dict[str, Any] | None = Field(default=None, description="Integration-specific credentials")
@@ -46,6 +47,9 @@ class ServerConfig(BaseSettings):
46
47
 
47
48
  name: str = Field(default="Universal MCP", description="Name of the MCP server")
48
49
  description: str = Field(default="Universal MCP", description="Description of the MCP server")
50
+ base_url: str = Field(
51
+ default="https://api.agentr.dev", description="Base URL for AgentR API", alias="AGENTR_BASE_URL"
52
+ )
49
53
  api_key: SecretStr | None = Field(default=None, description="API key for authentication", alias="AGENTR_API_KEY")
50
54
  type: Literal["local", "agentr"] = Field(default="agentr", description="Type of server deployment")
51
55
  transport: Literal["stdio", "sse", "streamable-http"] = Field(
@@ -70,3 +74,58 @@ class ServerConfig(BaseSettings):
70
74
  if not 1 <= v <= 65535:
71
75
  raise ValueError("Port must be between 1 and 65535")
72
76
  return v
77
+
78
+ @classmethod
79
+ def load_json_config(cls, path: str = "local_config.json") -> Self:
80
+ with open(path) as f:
81
+ data = json.load(f)
82
+ return cls.model_validate(data)
83
+
84
+
85
+ class ClientTransportConfig(BaseModel):
86
+ transport: str | None = None
87
+ command: str | None = None
88
+ args: list[str] = []
89
+ env: dict[str, str] = {}
90
+ url: str | None = None
91
+ headers: dict[str, str] = {}
92
+
93
+ @model_validator(mode="after")
94
+ def model_validate(self) -> Self:
95
+ """
96
+ Set the transport type based on the presence of command or url.
97
+ - If command is present, transport is 'stdio'.
98
+ - Else if url ends with 'mcp', transport is 'streamable_http'.
99
+ - Else, transport is 'sse'.
100
+ """
101
+ if self.command:
102
+ self.transport = "stdio"
103
+ elif self.url:
104
+ # Remove search params from url
105
+ url = self.url.split("?")[0]
106
+ if url.rstrip("/").endswith("mcp"):
107
+ self.transport = "streamable_http"
108
+ elif url.rstrip("/").endswith("sse"):
109
+ self.transport = "sse"
110
+ else:
111
+ raise ValueError(f"Unknown transport: {self.url}")
112
+ else:
113
+ raise ValueError("Either command or url must be provided")
114
+ return self
115
+
116
+
117
+ class LLMConfig(BaseModel):
118
+ api_key: str
119
+ base_url: str
120
+ model: str
121
+
122
+
123
+ class ClientConfig(BaseSettings):
124
+ mcpServers: dict[str, ClientTransportConfig]
125
+ llm: LLMConfig | None = None
126
+
127
+ @classmethod
128
+ def load_json_config(cls, path: str = "servers.json") -> Self:
129
+ with open(path) as f:
130
+ data = json.load(f)
131
+ return cls.model_validate(data)
@@ -325,9 +325,9 @@ class AgentRIntegration(Integration):
325
325
  ValueError: If no API key is provided or found in environment variables
326
326
  """
327
327
 
328
- def __init__(self, name: str, api_key: str | None = None, **kwargs):
328
+ def __init__(self, name: str, api_key: str | None = None, base_url: str | None = None, **kwargs):
329
329
  super().__init__(name, **kwargs)
330
- self.client = AgentrClient(api_key=api_key)
330
+ self.client = AgentrClient(api_key=api_key, base_url=base_url)
331
331
  self._credentials = None
332
332
 
333
333
  def set_credentials(self, credentials: dict | None = None):
@@ -206,8 +206,8 @@ class AgentRServer(BaseServer):
206
206
  self.api_key = config.api_key.get_secret_value() if config.api_key else None
207
207
  if not self.api_key:
208
208
  raise ValueError("API key is required for AgentR server")
209
- logger.info(f"Initializing AgentR server with API key: {self.api_key}")
210
- self.client = AgentrClient(api_key=self.api_key)
209
+ logger.info(f"Initializing AgentR server with API key: {self.api_key} and base URL: {config.base_url}")
210
+ self.client = AgentrClient(api_key=self.api_key, base_url=config.base_url)
211
211
  self._load_apps()
212
212
 
213
213
  def _fetch_apps(self) -> list[AppConfig]:
@@ -245,7 +245,7 @@ class AgentRServer(BaseServer):
245
245
  """
246
246
  try:
247
247
  integration = (
248
- AgentRIntegration(name=app_config.integration.name, api_key=self.api_key)
248
+ AgentRIntegration(name=app_config.integration.name, api_key=self.api_key, base_url=self.config.base_url)
249
249
  if app_config.integration
250
250
  else None
251
251
  )
@@ -276,10 +276,10 @@ class AgentRServer(BaseServer):
276
276
  else:
277
277
  logger.info(f"Successfully loaded {loaded_apps}/{len(app_configs)} apps from AgentR")
278
278
 
279
- except Exception:
279
+ except Exception as e:
280
280
  logger.error("Failed to load apps", exc_info=True)
281
281
  # Don't raise the exception to allow server to start with partial functionality
282
- logger.warning("Server will start with limited functionality due to app loading failures")
282
+ logger.warning(f"Server will start with limited functionality due to app loading failures: {e}")
283
283
 
284
284
 
285
285
  class SingleMCPServer(BaseServer):
@@ -102,3 +102,19 @@ def convert_tool_to_openai_tool(
102
102
  }
103
103
  logger.debug(f"Successfully converted tool '{tool.name}' to OpenAI format")
104
104
  return openai_tool
105
+
106
+
107
+ def transform_mcp_tool_to_openai_tool(mcp_tool: Tool):
108
+ """Convert an MCP tool to an OpenAI tool."""
109
+ from openai.types import FunctionDefinition
110
+ from openai.types.chat import ChatCompletionToolParam
111
+
112
+ return ChatCompletionToolParam(
113
+ type="function",
114
+ function=FunctionDefinition(
115
+ name=mcp_tool.name,
116
+ description=mcp_tool.description or "",
117
+ parameters=mcp_tool.inputSchema,
118
+ strict=False,
119
+ ),
120
+ )
@@ -15,11 +15,11 @@ class APISegmentBase:
15
15
  def _get(self, url: str, params: dict = None, **kwargs):
16
16
  return self.main_app_client._get(url, params=params, **kwargs)
17
17
 
18
- def _post(self, url: str, data: Any = None, files: Any = None, params: dict = None, content_type: str = None, **kwargs):
19
- return self.main_app_client._post(url, data=data, files=files, params=params, content_type=content_type, **kwargs)
18
+ def _post(self, url: str, data: Any = None, params: dict = None, content_type: str = None, files: Any = None, **kwargs):
19
+ return self.main_app_client._post(url, data=data, params=params, content_type=content_type, files=files, **kwargs)
20
20
 
21
- def _put(self, url: str, data: Any = None, files: Any = None, params: dict = None, content_type: str = None, **kwargs):
22
- return self.main_app_client._put(url, data=data, files=files, params=params, content_type=content_type, **kwargs)
21
+ def _put(self, url: str, data: Any = None, params: dict = None, content_type: str = None, files: Any = None, **kwargs):
22
+ return self.main_app_client._put(url, data=data, params=params, content_type=content_type, files=files, **kwargs)
23
23
 
24
24
  def _patch(self, url: str, data: Any = None, params: dict = None, **kwargs):
25
25
  return self.main_app_client._patch(url, data=data, params=params, **kwargs)
@@ -27,20 +27,9 @@ class APISegmentBase:
27
27
  def _delete(self, url: str, params: dict = None, **kwargs):
28
28
  return self.main_app_client._delete(url, params=params, **kwargs)
29
29
 
30
- def _get_json(self, url: str, params: dict = None, **kwargs):
31
- return self.main_app_client._get_json(url, params=params, **kwargs)
30
+ def _handle_response(self, response):
31
+ return self.main_app_client._handle_response(response)
32
32
 
33
- def _post_json(self, url: str, data: Any = None, files: Any = None, params: dict = None, content_type: str = "application/json", **kwargs):
34
- return self.main_app_client._post_json(url, data=data, files=files, params=params, content_type=content_type, **kwargs)
35
-
36
- def _put_json(self, url: str, data: Any = None, files: Any = None, params: dict = None, content_type: str = "application/json", **kwargs):
37
- return self.main_app_client._put_json(url, data=data, files=files, params=params, content_type=content_type, **kwargs)
38
-
39
- def _patch_json(self, url: str, data: Any = None, params: dict = None, **kwargs):
40
- return self.main_app_client._patch_json(url, data=data, params=params, **kwargs)
41
-
42
- def _delete_json(self, url: str, params: dict = None, **kwargs):
43
- return self.main_app_client._delete_json(url, params=params, **kwargs)
44
33
  """
45
34
 
46
35
 
@@ -165,7 +154,8 @@ class MethodTransformer(ast.NodeTransformer):
165
154
  return self.generic_visit(node)
166
155
 
167
156
 
168
- def split_generated_app_file(input_app_file: Path, output_dir: Path):
157
+ def split_generated_app_file(input_app_file: Path, output_dir: Path, package_name: str = None):
158
+
169
159
  content = input_app_file.read_text()
170
160
  tree = ast.parse(content)
171
161
 
@@ -515,7 +505,7 @@ def split_generated_app_file(input_app_file: Path, output_dir: Path):
515
505
  # Adjust import path for segments subfolder
516
506
  final_main_module_imports.append(
517
507
  ast.ImportFrom(
518
- module=f".{segments_foldername}.{seg_detail['module_name']}",
508
+ module=f"universal_mcp_{package_name}.{segments_foldername}.{seg_detail['module_name']}",
519
509
  names=[ast.alias(name=seg_detail["class_name"])],
520
510
  level=0,
521
511
  )
@@ -150,7 +150,7 @@ def _sanitize_identifier(name: str | None) -> str:
150
150
 
151
151
  # Initial replacements for common non-alphanumeric characters
152
152
  sanitized = (
153
- name.replace("-", "_").replace(".", "_").replace("[", "_").replace("]", "").replace("$", "_").replace("/", "_")
153
+ name.replace("-", "_").replace(".", "_").replace("[", "_").replace("]", "").replace("$", "_").replace("/", "_").replace("@", "at")
154
154
  )
155
155
 
156
156
  # Remove leading underscores, but preserve a single underscore if the name (after initial replace)
@@ -971,33 +971,37 @@ def _generate_method_code(path, method, operation):
971
971
  # Use convenience methods that automatically handle responses and errors
972
972
 
973
973
  if method_lower == "get":
974
- body_lines.append(" return self._get_json(url, params=query_params)")
974
+ body_lines.append(" response = self._get(url, params=query_params)")
975
+ body_lines.append(" return self._handle_response(response)")
975
976
  elif method_lower == "post":
976
977
  if selected_content_type == "multipart/form-data":
977
978
  body_lines.append(
978
- f" return self._post_json(url, data=request_body_data, files=files_data, params=query_params, content_type='{final_content_type_for_api_call}')"
979
+ f" response = self._post(url, data=request_body_data, files=files_data, params=query_params, content_type='{final_content_type_for_api_call}')"
979
980
  )
980
981
  else:
981
982
  body_lines.append(
982
- f" return self._post_json(url, data=request_body_data, params=query_params, content_type='{final_content_type_for_api_call}')"
983
+ f" response = self._post(url, data=request_body_data, params=query_params, content_type='{final_content_type_for_api_call}')"
983
984
  )
985
+ body_lines.append(" return self._handle_response(response)")
984
986
  elif method_lower == "put":
985
987
  if selected_content_type == "multipart/form-data":
986
988
  body_lines.append(
987
- f" return self._put_json(url, data=request_body_data, files=files_data, params=query_params, content_type='{final_content_type_for_api_call}')"
989
+ f" response = self._put(url, data=request_body_data, files=files_data, params=query_params, content_type='{final_content_type_for_api_call}')"
988
990
  )
989
991
  else:
990
992
  body_lines.append(
991
- f" return self._put_json(url, data=request_body_data, params=query_params, content_type='{final_content_type_for_api_call}')"
993
+ f" response = self._put(url, data=request_body_data, params=query_params, content_type='{final_content_type_for_api_call}')"
992
994
  )
995
+ body_lines.append(" return self._handle_response(response)")
993
996
  elif method_lower == "patch":
994
- body_lines.append(" return self._patch_json(url, data=request_body_data, params=query_params)")
997
+ body_lines.append(" response = self._patch(url, data=request_body_data, params=query_params)")
998
+ body_lines.append(" return self._handle_response(response)")
995
999
  elif method_lower == "delete":
996
- body_lines.append(" return self._delete_json(url, params=query_params)")
1000
+ body_lines.append(" response = self._delete(url, params=query_params)")
1001
+ body_lines.append(" return self._handle_response(response)")
997
1002
  else:
998
- body_lines.append(f" return self._{method_lower}_json(url, data=request_body_data, params=query_params)")
999
-
1000
- # No need for manual response handling anymore - convenience methods handle it automatically
1003
+ body_lines.append(f" response = self._{method_lower}(url, data=request_body_data, params=query_params)")
1004
+ body_lines.append(" return self._handle_response(response)")
1001
1005
 
1002
1006
  # --- Combine Signature, Docstring, and Body for Final Method Code ---
1003
1007
  method_code = signature + formatted_docstring + "\n" + "\n".join(body_lines)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: universal-mcp
3
- Version: 0.1.23rc1
3
+ Version: 0.1.23rc2
4
4
  Summary: Universal MCP acts as a middle ware for your API applications. It can store your credentials, authorize, enable disable apps on the fly and much more.
5
5
  Author-email: Manoj Bajaj <manojbajaj95@gmail.com>
6
6
  License: MIT
@@ -15,7 +15,7 @@ Requires-Dist: keyring>=25.6.0
15
15
  Requires-Dist: langchain-mcp-adapters>=0.0.3
16
16
  Requires-Dist: litellm>=1.30.7
17
17
  Requires-Dist: loguru>=0.7.3
18
- Requires-Dist: mcp>=1.9.0
18
+ Requires-Dist: mcp>=1.9.3
19
19
  Requires-Dist: posthog>=3.24.0
20
20
  Requires-Dist: pydantic-settings>=2.8.1
21
21
  Requires-Dist: pydantic>=2.11.1
@@ -1,25 +1,30 @@
1
1
  universal_mcp/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
2
  universal_mcp/analytics.py,sha256=Dkv8mkc_2T2t5NxLSZzcr3BlmOispj1RKtbB86V1i4M,2306
3
- universal_mcp/cli.py,sha256=-MVcqDEL_0AefmEgjq3ZCa2tySxXwi-Tfoat2kboX_U,10311
4
- universal_mcp/config.py,sha256=HaAZvf-XzQZpqGGWUuT5zojWloO8GL5Acfa5_0sDs_Q,3321
3
+ universal_mcp/cli.py,sha256=Ndc1I4BtmvDCM6xNtXaO6roUvNabRlclI3tLDeSHKAw,10453
4
+ universal_mcp/config.py,sha256=9yofM9MCyBiriojFVcMqRwc9njce6lgwX5r_EfvG0NE,5185
5
5
  universal_mcp/exceptions.py,sha256=-pbeZhpNieJfnSd2-WM80pU8W8mK8VHXcSjky0BHwdk,665
6
6
  universal_mcp/logger.py,sha256=VmH_83efpErLEDTJqz55Dp0dioTXfGvMBLZUx5smOLc,2116
7
7
  universal_mcp/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
8
  universal_mcp/applications/README.md,sha256=eqbizxaTxKH2O1tyIJR2yI0Db5TQxtgPd_vbpWyCa2Y,3527
9
9
  universal_mcp/applications/__init__.py,sha256=l19_sMs5766VFWU_7O2niamvvvfQOteysqylbqvjjGQ,3500
10
- universal_mcp/applications/application.py,sha256=-5uHUJORRjRnOwDbqJO4qSJLrFSGRghaUWOKMqhN5vo,22891
10
+ universal_mcp/applications/application.py,sha256=_fcnfAeT5Lm2PJpHjK5ax8pCsxlDGJIKyRqnlCdEg_w,18758
11
+ universal_mcp/client/__main__.py,sha256=72e4WyJRiLoCtHlpIuvMZcDV98LvZclVaUGJmuCvpIE,886
12
+ universal_mcp/client/agent.py,sha256=20N0aZ55ge9PBg-R9gHxit2NSY1ppSz2ez8ZXTiO5kc,3803
13
+ universal_mcp/client/client.py,sha256=rx-meuWZuurjaTbjumpf5NO9zIh4wH9nMYUAp2uAejU,8393
14
+ universal_mcp/client/oauth.py,sha256=H7goK8FcVuITiwdXR30Am-qAWcgNylsrvt9EJHZMayA,4144
15
+ universal_mcp/client/token_store.py,sha256=eoZJbVcmkR-mAGhwk1tmDXXZnDKvjoqOVZG3nXfI8f8,1234
11
16
  universal_mcp/integrations/README.md,sha256=lTAPXO2nivcBe1q7JT6PRa6v9Ns_ZersQMIdw-nmwEA,996
12
17
  universal_mcp/integrations/__init__.py,sha256=X8iEzs02IlXfeafp6GMm-cOkg70QdjnlTRuFo24KEfo,916
13
- universal_mcp/integrations/integration.py,sha256=2Wv9g5fJ4cbsTNsp4WcFNKdQCnj6rbBhgNQgMDAQ1Os,13057
18
+ universal_mcp/integrations/integration.py,sha256=WtW92Awr111Vb_i_vKBvyyYvsNton3N2cX7wQ3ZXy2Y,13105
14
19
  universal_mcp/servers/README.md,sha256=ytFlgp8-LO0oogMrHkMOp8SvFTwgsKgv7XhBVZGNTbM,2284
15
20
  universal_mcp/servers/__init__.py,sha256=eBZCsaZjiEv6ZlRRslPKgurQxmpHLQyiXv2fTBygHnM,532
16
- universal_mcp/servers/server.py,sha256=K7sPdCixYgJmQRxOL1icscL7-52sVsghpRX_D_uREu4,12329
21
+ universal_mcp/servers/server.py,sha256=FqAdAAXh23_ZL7ghZvXR5quAidXxQ6m-vDQrim40tag,12429
17
22
  universal_mcp/stores/README.md,sha256=jrPh_ow4ESH4BDGaSafilhOVaN8oQ9IFlFW-j5Z5hLA,2465
18
23
  universal_mcp/stores/__init__.py,sha256=quvuwhZnpiSLuojf0NfmBx2xpaCulv3fbKtKaSCEmuM,603
19
24
  universal_mcp/stores/store.py,sha256=mxnmOVlDNrr8OKhENWDtCIfK7YeCBQcGdS6I2ogRCsU,6756
20
25
  universal_mcp/tools/README.md,sha256=RuxliOFqV1ZEyeBdj3m8UKfkxAsfrxXh-b6V4ZGAk8I,2468
21
26
  universal_mcp/tools/__init__.py,sha256=Fatza_R0qYWmNF1WQSfUZZKQFu5qf-16JhZzdmyx3KY,333
22
- universal_mcp/tools/adapters.py,sha256=nMoZ9jnv1uKhfq6NmBJ5-a6uwdB_H8RqkdNLIacCRfM,2978
27
+ universal_mcp/tools/adapters.py,sha256=rnicV_WBgNe0WqVG60ms0o92BoRTRqd_C9jMMndx47c,3461
23
28
  universal_mcp/tools/func_metadata.py,sha256=7kUWArtUDa2Orr7VGzpwPVfyf2LM3UFA_9arMpl7Zn8,10838
24
29
  universal_mcp/tools/manager.py,sha256=ao_ovTyca8HR4uwHdL_lTWNdquxcqRx6FaLA4U1lZvQ,11242
25
30
  universal_mcp/tools/tools.py,sha256=8S_KzARYbG9xbyqhZcI4Wk46tXiZcWlcAMgjChXNEI4,3698
@@ -32,15 +37,15 @@ universal_mcp/utils/singleton.py,sha256=kolHnbS9yd5C7z-tzaUAD16GgI-thqJXysNi3sZM
32
37
  universal_mcp/utils/testing.py,sha256=0znYkuFi8-WjOdbwrTbNC-UpMqG3EXcGOE0wxlERh_A,1464
33
38
  universal_mcp/utils/openapi/__inti__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
34
39
  universal_mcp/utils/openapi/api_generator.py,sha256=FjtvbnWuI1P8W8wXuKLCirUtsqQ4HI_TuQrhpA4SqTs,4749
35
- universal_mcp/utils/openapi/api_splitter.py,sha256=hED34exwKcBtKzkz-3jVWzNeFBNGgjxANQeu1FibuOU,21818
40
+ universal_mcp/utils/openapi/api_splitter.py,sha256=0hoq6KMcDVA5NynDBrFjjqjDugZvaWGBdfLzvyLkFAM,20968
36
41
  universal_mcp/utils/openapi/docgen.py,sha256=DNmwlhg_-TRrHa74epyErMTRjV2nutfCQ7seb_Rq5hE,21366
37
- universal_mcp/utils/openapi/openapi.py,sha256=tUD3HNLGAF808AszHLGGKPqpqLT-PZB_8LwagyvsWKQ,50828
42
+ universal_mcp/utils/openapi/openapi.py,sha256=8TV7L1Wm9_mi_1iy0ccf_4H39XLuZjIinBnJ56H-ggw,51197
38
43
  universal_mcp/utils/openapi/preprocessor.py,sha256=PPIM3Uu8DYi3dRKdqi9thr9ufeUgkr2K08ri1BwKpoQ,60835
39
44
  universal_mcp/utils/openapi/readme.py,sha256=R2Jp7DUXYNsXPDV6eFTkLiy7MXbSULUj1vHh4O_nB4c,2974
40
45
  universal_mcp/utils/templates/README.md.j2,sha256=Mrm181YX-o_-WEfKs01Bi2RJy43rBiq2j6fTtbWgbTA,401
41
46
  universal_mcp/utils/templates/api_client.py.j2,sha256=972Im7LNUAq3yZTfwDcgivnb-b8u6_JLKWXwoIwXXXQ,908
42
- universal_mcp-0.1.23rc1.dist-info/METADATA,sha256=qsh7rNBIpaSDFBkw7vmIUpDgM4YYkriK9HYeDOLJpwE,12154
43
- universal_mcp-0.1.23rc1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
44
- universal_mcp-0.1.23rc1.dist-info/entry_points.txt,sha256=QlBrVKmA2jIM0q-C-3TQMNJTTWOsOFQvgedBq2rZTS8,56
45
- universal_mcp-0.1.23rc1.dist-info/licenses/LICENSE,sha256=NweDZVPslBAZFzlgByF158b85GR0f5_tLQgq1NS48To,1063
46
- universal_mcp-0.1.23rc1.dist-info/RECORD,,
47
+ universal_mcp-0.1.23rc2.dist-info/METADATA,sha256=oRusZjRll0c923kS-RTc64lZw_XTVk8NSuv1gKis1Lk,12154
48
+ universal_mcp-0.1.23rc2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
49
+ universal_mcp-0.1.23rc2.dist-info/entry_points.txt,sha256=QlBrVKmA2jIM0q-C-3TQMNJTTWOsOFQvgedBq2rZTS8,56
50
+ universal_mcp-0.1.23rc2.dist-info/licenses/LICENSE,sha256=NweDZVPslBAZFzlgByF158b85GR0f5_tLQgq1NS48To,1063
51
+ universal_mcp-0.1.23rc2.dist-info/RECORD,,