sunholo 0.143.15__py3-none-any.whl → 0.144.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.
sunholo/mcp/cli.py CHANGED
@@ -1,338 +1,22 @@
1
- import os
2
- import asyncio
3
- from typing import Any, Sequence
4
- from functools import lru_cache
5
- import subprocess
6
-
7
- try:
8
- from mcp.server import Server
9
- from mcp.server.stdio import stdio_server
10
- from mcp.types import (
11
- Resource,
12
- Tool,
13
- TextContent,
14
- ImageContent,
15
- EmbeddedResource,
16
- )
17
-
18
- from rich import print
19
- from ..cli.sun_rich import console
20
- except ImportError:
21
- Server = None
22
- console = None
23
-
24
- from pydantic import AnyUrl
25
-
26
- # Configure logging
27
- from ..custom_logging import setup_logging
28
- logger = setup_logging("sunholo-mcp")
29
-
30
- class SunholoMCPServer:
31
- def __init__(self):
32
- logger.info("Initializing Sunholo MCP Server")
33
-
34
- if Server is None:
35
- raise ImportError("SunholoMCPServer requires `sunholo[anthropic]` to be installed")
36
-
37
- self.server = Server("sunholo-mcp-server")
38
- self.server.onerror = self.handle_error
39
-
40
- self.setup_handlers()
41
-
42
- def handle_error(self, error: Exception):
43
- """Handle server errors"""
44
- logger.error(f"MCP Server error: {error}", exc_info=True)
45
-
46
- def setup_handlers(self):
47
- """Set up all the MCP protocol handlers"""
48
- self.setup_resource_handlers()
49
- self.setup_tool_handlers()
50
-
51
- def setup_resource_handlers(self):
52
- """Configure resource-related handlers"""
53
-
54
- @self.server.list_resources()
55
- async def list_resources() -> list[Resource]:
56
- """List available Sunholo resources"""
57
- return [
58
- Resource(
59
- uri="sunholo://vacs/list",
60
- name="Available Sunholo VACs",
61
- mimeType="application/json",
62
- description="List of available Virtual Agent Computers"
63
- )
64
- ]
65
-
66
- @self.server.read_resource()
67
- async def read_resource(uri: AnyUrl) -> str:
68
- """Read Sunholo resources based on URI"""
69
- logger.info(f"{uri} available")
70
- if str(uri) == "sunholo://vacs/list":
71
- try:
72
- # Execute sunholo vac list command
73
- result = subprocess.run(
74
- ["sunholo", "vac", "list"],
75
- capture_output=True,
76
- text=True
77
- )
78
- return result.stdout
79
- except subprocess.CalledProcessError as e:
80
- raise RuntimeError(f"Failed to list VACs: {str(e)}")
81
- except Exception as e:
82
- raise RuntimeError(f"Error accessing Sunholo: {str(e)}")
83
-
84
- raise ValueError(f"Unknown resource: {uri}")
85
-
86
- def setup_tool_handlers(self):
87
- """Configure tool-related handlers"""
88
-
89
- @self.server.list_tools()
90
- async def list_tools() -> list[Tool]:
91
- """List available Sunholo tools"""
92
- return [
93
- Tool(
94
- name="chat_with_vac",
95
- description="Chat with a specific Sunholo VAC",
96
- inputSchema={
97
- "type": "object",
98
- "properties": {
99
- "vac_name": {
100
- "type": "string",
101
- "description": "Name of the VAC to chat with"
102
- },
103
- "message": {
104
- "type": "string",
105
- "description": "Message to send to the VAC"
106
- },
107
- },
108
- "required": ["vac_name", "message"]
109
- }
110
- ),
111
- Tool(
112
- name="list_configs",
113
- description="List Sunholo configurations",
114
- inputSchema={
115
- "type": "object",
116
- "properties": {
117
- "kind": {
118
- "type": "string",
119
- "description": "Filter configurations by kind e.g. vacConfig"
120
- },
121
- "vac": {
122
- "type": "string",
123
- "description": "Filter configurations by VAC name"
124
- },
125
- "validate": {
126
- "type": "boolean",
127
- "description": "Validate the configuration files"
128
- }
129
- }
130
- }
131
- ),
132
- Tool(
133
- name="embed_content",
134
- description="Embed content in a VAC's vector store",
135
- inputSchema={
136
- "type": "object",
137
- "properties": {
138
- "vac_name": {
139
- "type": "string",
140
- "description": "Name of the VAC to embed content for"
141
- },
142
- "content": {
143
- "type": "string",
144
- "description": "Content to embed"
145
- },
146
- "local_chunks": {
147
- "type": "boolean",
148
- "description": "Whether to process chunks locally",
149
- "default": False
150
- }
151
- },
152
- "required": ["vac_name", "content"]
153
- }
154
- )
155
- ]
156
-
157
- @self.server.call_tool()
158
- async def call_tool(
159
- name: str,
160
- arguments: Any
161
- ) -> Sequence[TextContent | ImageContent | EmbeddedResource]:
162
- """Handle tool calls for Sunholo interactions"""
163
-
164
- if name == "chat_with_vac":
165
- if not isinstance(arguments, dict):
166
- raise ValueError("Invalid arguments format")
167
-
168
- vac_name = arguments.get("vac_name")
169
- message = arguments.get("message")
170
-
171
- if not vac_name or not message:
172
- raise ValueError("Missing required arguments")
173
-
174
- try:
175
- cmd = ["sunholo", "vac", "chat", vac_name, message]
176
- cmd.append("--headless")
177
-
178
- result = subprocess.run(
179
- cmd,
180
- capture_output=True,
181
- text=True
182
- )
183
-
184
- return [
185
- TextContent(
186
- type="text",
187
- text=result.stdout
188
- )
189
- ]
190
- except subprocess.CalledProcessError as e:
191
- return [
192
- TextContent(
193
- type="text",
194
- text=f"Error chatting with VAC: {e.stderr}"
195
- )
196
- ]
197
-
198
- elif name == "embed_content":
199
- if not isinstance(arguments, dict):
200
- raise ValueError("Invalid arguments format")
201
-
202
- vac_name = arguments.get("vac_name")
203
- content = arguments.get("content")
204
- local_chunks = arguments.get("local_chunks", False)
205
-
206
- if not vac_name or not content:
207
- raise ValueError("Missing required arguments")
208
-
209
- try:
210
- cmd = ["sunholo", "embed", vac_name, content]
211
- if local_chunks:
212
- cmd.append("--local-chunks")
213
-
214
- result = subprocess.run(
215
- cmd,
216
- capture_output=True,
217
- text=True
218
- )
219
-
220
- return [
221
- TextContent(
222
- type="text",
223
- text=result.stdout
224
- )
225
- ]
226
- except subprocess.CalledProcessError as e:
227
- return [
228
- TextContent(
229
- type="text",
230
- text=f"Error embedding content: {e.stderr}"
231
- )
232
- ]
233
- elif name == "list_configs":
234
-
235
- # Build command
236
- cmd = ["sunholo", "list-configs"]
237
-
238
- if arguments.get("kind"):
239
- cmd.extend(["--kind", arguments["kind"]])
240
- if arguments.get("vac"):
241
- cmd.extend(["--vac", arguments["vac"]])
242
- if arguments.get("validate"):
243
- cmd.append("--validate")
244
-
245
- # Execute sunholo command
246
- try:
247
- result = subprocess.run(cmd, capture_output=True, text=True)
248
- return [TextContent(type="text", text=result.stdout)]
249
- except subprocess.CalledProcessError as e:
250
- return [TextContent(type="text", text=f"Error: {e.stderr}")]
251
-
252
-
253
- raise ValueError(f"Unknown tool: {name}")
254
-
255
- async def run(self):
256
- """Run the MCP server"""
257
- async with stdio_server() as (read_stream, write_stream):
258
- await self.server.run(
259
- read_stream,
260
- write_stream,
261
- self.server.create_initialization_options()
262
- )
263
-
264
- def cli_mcp(args):
265
- """CLI handler for the MCP server command"""
266
- try:
267
-
268
- if not os.getenv("VAC_CONFIG_FOLDER"):
269
- raise ValueError("sunholo configuration folder must be present in config/ or via VAC_CONFIG_FOLDER")
270
-
271
- # Create and run the MCP server
272
- server = SunholoMCPServer()
273
- msg = {"message": "Starting Sunholo MCP server..."}
274
-
275
- logger.info(msg)
276
- console.print(msg)
277
-
278
- asyncio.run(server.run())
279
-
280
- except Exception as e:
281
- logger.error(f"Error running MCP server: {str(e)}")
282
- raise
283
-
284
- def cli_mcp_bridge(args):
285
- """CLI handler for the MCP bridge command"""
286
- try:
287
- from .stdio_http_bridge import run_bridge
288
-
289
- http_url = args.url
290
- logger.info(f"Starting MCP stdio-to-HTTP bridge for {http_url}")
291
-
292
- if console:
293
- console.print(f"Starting MCP bridge to {http_url}...")
294
-
295
- asyncio.run(run_bridge(http_url))
296
-
297
- except KeyboardInterrupt:
298
- logger.info("Bridge stopped by user")
299
- except Exception as e:
300
- logger.error(f"Error running MCP bridge: {str(e)}")
301
- raise
302
-
303
- def setup_mcp_subparser(subparsers):
304
- """
305
- Sets up an argparse subparser for the 'mcp' command 3.
306
-
307
- By default will use configurations within the folder specified by 'VAC_CONFIG_FOLDER'
308
-
309
- Example command:
310
- ```bash
311
- sunholo mcp
312
- ```
313
- """
314
- mcp_parser = subparsers.add_parser('mcp',
315
- help='MCP (Model Context Protocol) commands')
316
-
317
- # Create subcommands for mcp
318
- mcp_subparsers = mcp_parser.add_subparsers(title='mcp commands',
319
- description='MCP subcommands',
320
- help='MCP subcommands',
321
- dest='mcp_command')
322
-
323
- # mcp server command (default)
324
- server_parser = mcp_subparsers.add_parser('server',
325
- help='Start an Anthropic MCP server that wraps sunholo functionality')
326
- server_parser.set_defaults(func=cli_mcp)
327
-
328
- # mcp bridge command
329
- bridge_parser = mcp_subparsers.add_parser('bridge',
330
- help='Start a stdio-to-HTTP bridge for MCP servers')
331
- bridge_parser.add_argument('url',
332
- nargs='?',
333
- default='http://127.0.0.1:1956/mcp',
334
- help='HTTP URL of the MCP server (default: http://127.0.0.1:1956/mcp)')
335
- bridge_parser.set_defaults(func=cli_mcp_bridge)
336
-
337
- # Set default behavior when no subcommand is provided
338
- mcp_parser.set_defaults(func=cli_mcp)
1
+ # Copyright [2024] [Holosun ApS]
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """
16
+ CLI for MCP server commands using FastMCP.
17
+ """
18
+
19
+ # Import the FastMCP implementation
20
+ from .cli_fastmcp import cli_mcp, cli_mcp_bridge, setup_mcp_subparser
21
+
22
+ __all__ = ['cli_mcp', 'cli_mcp_bridge', 'setup_mcp_subparser']
@@ -0,0 +1,201 @@
1
+ import os
2
+ import asyncio
3
+ import subprocess
4
+ from typing import List, Optional
5
+
6
+ from fastmcp import FastMCP
7
+
8
+ # Configure logging
9
+ from ..custom_logging import setup_logging
10
+ logger = setup_logging("sunholo-mcp")
11
+
12
+ # Initialize FastMCP server
13
+ mcp = FastMCP("sunholo-mcp-server")
14
+
15
+ @mcp.resource("sunholo://vacs/list")
16
+ async def list_vacs() -> str:
17
+ """
18
+ List of available Virtual Agent Computers.
19
+
20
+ Returns the list of configured VACs in the system.
21
+ """
22
+ try:
23
+ result = subprocess.run(
24
+ ["sunholo", "vac", "list"],
25
+ capture_output=True,
26
+ text=True,
27
+ check=True
28
+ )
29
+ return result.stdout
30
+ except subprocess.CalledProcessError as e:
31
+ raise RuntimeError(f"Failed to list VACs: {str(e)}")
32
+ except Exception as e:
33
+ raise RuntimeError(f"Error accessing Sunholo: {str(e)}")
34
+
35
+ @mcp.tool
36
+ async def chat_with_vac(vac_name: str, message: str, headless: bool = True) -> str:
37
+ """
38
+ Chat with a specific Sunholo VAC.
39
+
40
+ Args:
41
+ vac_name: Name of the VAC to chat with
42
+ message: Message to send to the VAC
43
+ headless: Run in headless mode (default: True)
44
+
45
+ Returns:
46
+ Response from the VAC
47
+ """
48
+ try:
49
+ cmd = ["sunholo", "vac", "chat", vac_name, message]
50
+ if headless:
51
+ cmd.append("--headless")
52
+
53
+ result = subprocess.run(
54
+ cmd,
55
+ capture_output=True,
56
+ text=True,
57
+ check=True
58
+ )
59
+ return result.stdout
60
+ except subprocess.CalledProcessError as e:
61
+ return f"Error chatting with VAC: {e.stderr}"
62
+
63
+ @mcp.tool
64
+ async def list_configs(
65
+ kind: Optional[str] = None,
66
+ vac: Optional[str] = None,
67
+ validate: bool = False
68
+ ) -> str:
69
+ """
70
+ List Sunholo configurations.
71
+
72
+ Args:
73
+ kind: Filter configurations by kind (e.g., 'vacConfig')
74
+ vac: Filter configurations by VAC name
75
+ validate: Validate the configuration files
76
+
77
+ Returns:
78
+ Configuration listing output
79
+ """
80
+ cmd = ["sunholo", "list-configs"]
81
+
82
+ if kind:
83
+ cmd.extend(["--kind", kind])
84
+ if vac:
85
+ cmd.extend(["--vac", vac])
86
+ if validate:
87
+ cmd.append("--validate")
88
+
89
+ try:
90
+ result = subprocess.run(cmd, capture_output=True, text=True, check=True)
91
+ return result.stdout
92
+ except subprocess.CalledProcessError as e:
93
+ return f"Error: {e.stderr}"
94
+
95
+ @mcp.tool
96
+ async def embed_content(
97
+ vac_name: str,
98
+ content: str,
99
+ local_chunks: bool = False
100
+ ) -> str:
101
+ """
102
+ Embed content in a VAC's vector store.
103
+
104
+ Args:
105
+ vac_name: Name of the VAC to embed content for
106
+ content: Content to embed
107
+ local_chunks: Whether to process chunks locally
108
+
109
+ Returns:
110
+ Embedding operation result
111
+ """
112
+ try:
113
+ cmd = ["sunholo", "embed", vac_name, content]
114
+ if local_chunks:
115
+ cmd.append("--local-chunks")
116
+
117
+ result = subprocess.run(
118
+ cmd,
119
+ capture_output=True,
120
+ text=True,
121
+ check=True
122
+ )
123
+ return result.stdout
124
+ except subprocess.CalledProcessError as e:
125
+ return f"Error embedding content: {e.stderr}"
126
+
127
+ def cli_mcp(args):
128
+ """CLI handler for the MCP server command using FastMCP."""
129
+ try:
130
+ if not os.getenv("VAC_CONFIG_FOLDER"):
131
+ raise ValueError("sunholo configuration folder must be present in config/ or via VAC_CONFIG_FOLDER")
132
+
133
+ logger.info("Starting Sunholo MCP server with FastMCP...")
134
+
135
+ # Run the FastMCP server in stdio mode (default for Claude Desktop)
136
+ mcp.run()
137
+
138
+ except Exception as e:
139
+ logger.error(f"Error running MCP server: {str(e)}")
140
+ raise
141
+
142
+ def cli_mcp_bridge(args):
143
+ """
144
+ CLI handler for the MCP bridge command.
145
+ FastMCP handles HTTP transport natively, so this can be simplified.
146
+ """
147
+ try:
148
+ http_url = args.url
149
+
150
+ # Parse port from URL if provided
151
+ import urllib.parse
152
+ parsed = urllib.parse.urlparse(http_url)
153
+ port = parsed.port or 8000
154
+
155
+ logger.info(f"Starting FastMCP HTTP server on port {port}")
156
+
157
+ # Run FastMCP in HTTP mode
158
+ mcp.run(transport="http", port=port)
159
+
160
+ except KeyboardInterrupt:
161
+ logger.info("Server stopped by user")
162
+ except Exception as e:
163
+ logger.error(f"Error running MCP server: {str(e)}")
164
+ raise
165
+
166
+ def setup_mcp_subparser(subparsers):
167
+ """
168
+ Sets up an argparse subparser for the 'mcp' command.
169
+
170
+ By default will use configurations within the folder specified by 'VAC_CONFIG_FOLDER'
171
+
172
+ Example command:
173
+ ```bash
174
+ sunholo mcp
175
+ ```
176
+ """
177
+ mcp_parser = subparsers.add_parser('mcp',
178
+ help='MCP (Model Context Protocol) commands')
179
+
180
+ # Create subcommands for mcp
181
+ mcp_subparsers = mcp_parser.add_subparsers(title='mcp commands',
182
+ description='MCP subcommands',
183
+ help='MCP subcommands',
184
+ dest='mcp_command')
185
+
186
+ # mcp server command (default)
187
+ server_parser = mcp_subparsers.add_parser('server',
188
+ help='Start an Anthropic MCP server that wraps sunholo functionality')
189
+ server_parser.set_defaults(func=cli_mcp)
190
+
191
+ # mcp bridge command - now simplified with FastMCP
192
+ bridge_parser = mcp_subparsers.add_parser('bridge',
193
+ help='Start an HTTP MCP server (FastMCP handles transport)')
194
+ bridge_parser.add_argument('url',
195
+ nargs='?',
196
+ default='http://127.0.0.1:8000/mcp',
197
+ help='HTTP URL for the MCP server (default: http://127.0.0.1:8000/mcp)')
198
+ bridge_parser.set_defaults(func=cli_mcp_bridge)
199
+
200
+ # Set default behavior when no subcommand is provided
201
+ mcp_parser.set_defaults(func=cli_mcp)
@@ -11,7 +11,7 @@ import aiohttp
11
11
  from typing import Optional
12
12
 
13
13
  from ..custom_logging import setup_logging
14
- logger = setup_logging("mcp-bridge")
14
+ log = setup_logging("mcp-bridge")
15
15
 
16
16
  class MCPStdioHttpBridge:
17
17
  def __init__(self, http_url: str):
@@ -23,7 +23,7 @@ class MCPStdioHttpBridge:
23
23
  self.session = aiohttp.ClientSession()
24
24
 
25
25
  # Send initialization success to stderr for debugging
26
- logger.info(f"MCP stdio-to-HTTP bridge started, forwarding to: {self.http_url}")
26
+ log.info(f"MCP stdio-to-HTTP bridge started, forwarding to: {self.http_url}")
27
27
 
28
28
  try:
29
29
  await self.process_messages()
@@ -51,9 +51,9 @@ class MCPStdioHttpBridge:
51
51
  # Parse JSON-RPC message
52
52
  try:
53
53
  message = json.loads(line)
54
- logger.debug(f"Received from stdin: {message}")
54
+ log.debug(f"Received from stdin: {message}")
55
55
  except json.JSONDecodeError as e:
56
- logger.error(f"Invalid JSON: {e}")
56
+ log.error(f"Invalid JSON: {e}")
57
57
  continue
58
58
 
59
59
  # Forward to HTTP server
@@ -64,14 +64,14 @@ class MCPStdioHttpBridge:
64
64
  headers={"Content-Type": "application/json"}
65
65
  ) as response:
66
66
  result = await response.json()
67
- logger.debug(f"Received from HTTP: {result}")
67
+ log.debug(f"Received from HTTP: {result}")
68
68
 
69
69
  # Send response back to stdout
70
70
  print(json.dumps(result))
71
71
  sys.stdout.flush()
72
72
 
73
73
  except aiohttp.ClientError as e:
74
- logger.error(f"HTTP error: {e}")
74
+ log.error(f"HTTP error: {e}")
75
75
  error_response = {
76
76
  "jsonrpc": "2.0",
77
77
  "error": {
@@ -84,7 +84,7 @@ class MCPStdioHttpBridge:
84
84
  sys.stdout.flush()
85
85
 
86
86
  except Exception as e:
87
- logger.error(f"Bridge error: {e}")
87
+ log.error(f"Bridge error: {e}")
88
88
  continue
89
89
 
90
90
  async def run_bridge(http_url: str):