suprema-biostar-mcp 1.0.1__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.
- biostar_x_mcp_server/__init__.py +25 -0
- biostar_x_mcp_server/__main__.py +15 -0
- biostar_x_mcp_server/config.py +87 -0
- biostar_x_mcp_server/handlers/__init__.py +35 -0
- biostar_x_mcp_server/handlers/access_handler.py +2162 -0
- biostar_x_mcp_server/handlers/audit_handler.py +489 -0
- biostar_x_mcp_server/handlers/auth_handler.py +216 -0
- biostar_x_mcp_server/handlers/base_handler.py +228 -0
- biostar_x_mcp_server/handlers/card_handler.py +746 -0
- biostar_x_mcp_server/handlers/device_handler.py +4344 -0
- biostar_x_mcp_server/handlers/door_handler.py +3969 -0
- biostar_x_mcp_server/handlers/event_handler.py +1331 -0
- biostar_x_mcp_server/handlers/file_handler.py +212 -0
- biostar_x_mcp_server/handlers/help_web_handler.py +379 -0
- biostar_x_mcp_server/handlers/log_handler.py +1051 -0
- biostar_x_mcp_server/handlers/navigation_handler.py +109 -0
- biostar_x_mcp_server/handlers/occupancy_handler.py +541 -0
- biostar_x_mcp_server/handlers/user_handler.py +3568 -0
- biostar_x_mcp_server/schemas/__init__.py +21 -0
- biostar_x_mcp_server/schemas/access.py +158 -0
- biostar_x_mcp_server/schemas/audit.py +73 -0
- biostar_x_mcp_server/schemas/auth.py +24 -0
- biostar_x_mcp_server/schemas/cards.py +128 -0
- biostar_x_mcp_server/schemas/devices.py +496 -0
- biostar_x_mcp_server/schemas/doors.py +306 -0
- biostar_x_mcp_server/schemas/events.py +104 -0
- biostar_x_mcp_server/schemas/files.py +7 -0
- biostar_x_mcp_server/schemas/help.py +29 -0
- biostar_x_mcp_server/schemas/logs.py +33 -0
- biostar_x_mcp_server/schemas/occupancy.py +19 -0
- biostar_x_mcp_server/schemas/tool_response.py +29 -0
- biostar_x_mcp_server/schemas/users.py +166 -0
- biostar_x_mcp_server/server.py +335 -0
- biostar_x_mcp_server/session.py +221 -0
- biostar_x_mcp_server/tool_manager.py +172 -0
- biostar_x_mcp_server/tools/__init__.py +45 -0
- biostar_x_mcp_server/tools/access.py +510 -0
- biostar_x_mcp_server/tools/audit.py +227 -0
- biostar_x_mcp_server/tools/auth.py +59 -0
- biostar_x_mcp_server/tools/cards.py +269 -0
- biostar_x_mcp_server/tools/categories.py +197 -0
- biostar_x_mcp_server/tools/devices.py +1552 -0
- biostar_x_mcp_server/tools/doors.py +865 -0
- biostar_x_mcp_server/tools/events.py +305 -0
- biostar_x_mcp_server/tools/files.py +28 -0
- biostar_x_mcp_server/tools/help.py +80 -0
- biostar_x_mcp_server/tools/logs.py +123 -0
- biostar_x_mcp_server/tools/navigation.py +89 -0
- biostar_x_mcp_server/tools/occupancy.py +91 -0
- biostar_x_mcp_server/tools/users.py +1113 -0
- biostar_x_mcp_server/utils/__init__.py +31 -0
- biostar_x_mcp_server/utils/category_mapper.py +206 -0
- biostar_x_mcp_server/utils/decorators.py +101 -0
- biostar_x_mcp_server/utils/language_detector.py +51 -0
- biostar_x_mcp_server/utils/search.py +42 -0
- biostar_x_mcp_server/utils/timezone.py +122 -0
- suprema_biostar_mcp-1.0.1.dist-info/METADATA +163 -0
- suprema_biostar_mcp-1.0.1.dist-info/RECORD +61 -0
- suprema_biostar_mcp-1.0.1.dist-info/WHEEL +4 -0
- suprema_biostar_mcp-1.0.1.dist-info/entry_points.txt +2 -0
- suprema_biostar_mcp-1.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
from typing import Optional, Union, List, Dict, Any
|
|
2
|
+
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class CreateUserInput(BaseModel):
|
|
8
|
+
user_id: Optional[Union[str, int]] = Field(default=None, description="User ID (automatically generated if omitted)")
|
|
9
|
+
name: str = Field(..., min_length=1, max_length=48, description="User's full name (max 48 characters)")
|
|
10
|
+
email: Optional[str] = Field(None, pattern=r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', description="User's email address")
|
|
11
|
+
department: Optional[str] = Field(None, max_length=64, description="Department name (max 64 characters)")
|
|
12
|
+
user_title: Optional[str] = Field(None, description="Job title")
|
|
13
|
+
phone: Optional[str] = Field(None, description="Phone number")
|
|
14
|
+
disabled: Optional[bool] = Field(False, description="Whether the user is disabled (default: False)")
|
|
15
|
+
permission: Optional[int] = Field(None, description="Admin permission level (None for regular users)")
|
|
16
|
+
access_groups: Optional[List[int]] = Field(None, description="List of access group IDs")
|
|
17
|
+
login_id: Optional[str] = Field(None, description="Login ID (required for admin users)")
|
|
18
|
+
password: Optional[str] = Field(None, description="Password (required for admin users)")
|
|
19
|
+
user_ip: Optional[str] = Field(None, pattern=r'^(\d{1,3}\.){3}\d{1,3}$', description="IP address restriction (format: xxx.xxx.xxx.xxx)")
|
|
20
|
+
pin: Optional[int] = Field(None, ge=0, le=99999999999999999999999999999999, description="PIN number (up to 32 digits)")
|
|
21
|
+
photo: Optional[str] = Field(None, description="Base64-encoded user photo")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DeleteUserInput(BaseModel):
|
|
25
|
+
id: Optional[Union[str, int, List[Union[str, int]]]] = Field(None, description="ID(s) of the user to delete")
|
|
26
|
+
name: Optional[str] = Field(None, description="Name of the user to delete (supports partial match)")
|
|
27
|
+
confirm_multiple: Optional[bool] = Field(False, description="Safety flag: Must be true to delete multiple users at once")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class BulkAddUsersInput(BaseModel):
|
|
31
|
+
base_name: str = Field(default="user", min_length=1, max_length=40, description="Base prefix for user names to generate")
|
|
32
|
+
count: int = Field(default=10, ge=1, le=30, description="Number of users to create in bulk (1-30)")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class UpdateUserInput(BaseModel):
|
|
36
|
+
user_id: Optional[Union[str, int]] = Field(None, description="ID of the user to update")
|
|
37
|
+
name: Optional[str] = Field(None, description="Current name of the user (used to find the target)")
|
|
38
|
+
new_name: Optional[str] = Field(None, description="New name for the user")
|
|
39
|
+
user_group_id: Optional[int] = Field(None, description="ID of the user group to assign")
|
|
40
|
+
disabled: Optional[bool] = Field(None, description="Disable or enable the user")
|
|
41
|
+
start_datetime: Optional[str] = Field(None, description="Start date for user validity (ISO format or flexible formats like 20251225, 12.25.2025)")
|
|
42
|
+
expiry_datetime: Optional[str] = Field(None, description="Expiration date for user validity (ISO format or flexible formats like 20251225, 12.25.2025)")
|
|
43
|
+
start_date: Optional[str] = Field(None, description="Alias for start_datetime (flexible date format)")
|
|
44
|
+
end_date: Optional[str] = Field(None, description="Alias for expiry_datetime (flexible date format)")
|
|
45
|
+
email: Optional[str] = Field(None, description="Email address")
|
|
46
|
+
department: Optional[str] = Field(None, description="Department name")
|
|
47
|
+
user_title: Optional[str] = Field(None, description="Job title")
|
|
48
|
+
photo: Optional[str] = Field(None, description="Base64-encoded user photo")
|
|
49
|
+
phone: Optional[str] = Field(None, description="Phone number")
|
|
50
|
+
permission: Optional[int] = Field(None, description="Admin permission level")
|
|
51
|
+
access_groups: Optional[List[int]] = Field(None, description="List of access group IDs")
|
|
52
|
+
access_group_names: Optional[List[str]] = Field(None, description="List of access group names (alternative to access_groups)")
|
|
53
|
+
login_id: Optional[str] = Field(None, description="Login ID")
|
|
54
|
+
password: Optional[str] = Field(None, description="Login password")
|
|
55
|
+
user_ip: Optional[str] = Field(None, description="IP address restriction")
|
|
56
|
+
pin: Optional[int] = Field(None, description="PIN number")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class AdvancedSearchUserInput(BaseModel):
|
|
60
|
+
limit: Optional[int] = Field(50, description="The maximum number of users to return. Default is 50.")
|
|
61
|
+
offset: Optional[str] = Field("0", description="The number of users to skip before starting to return results. Default is 0.")
|
|
62
|
+
user_id: Optional[str] = Field(None, description="The User ID to search, supports like search")
|
|
63
|
+
user_group_id: Optional[str] = Field(None, description="Group ID for search user. Also gets the child user group")
|
|
64
|
+
user_group_ids: Optional[List[int]] = Field(None, description="Array of Group IDs for advanced search parameter")
|
|
65
|
+
user_name: Optional[str] = Field(None, description="User name for Advance Search parameter, supports like search both encrypted and unencrypted")
|
|
66
|
+
user_email: Optional[str] = Field(None, description="User email, supports like search both encrypted and unencrypted")
|
|
67
|
+
user_phone: Optional[str] = Field(None, description="User Phone for Advance Search parameter, supports like search both encrypted and unencrypted")
|
|
68
|
+
user_department: Optional[str] = Field(None, description="User Department for Advance Search parameter, supports like search on unencrypted and exact search on encrypted")
|
|
69
|
+
user_access_group_ids: Optional[str] = Field(None, description="User access group id for advance search parameter, supports multiple value with format String with Delimiter , i.e 1,2,3")
|
|
70
|
+
user_title: Optional[str] = Field(None, description="User title for advance search parameter, supports like search on unencrypted and exact search on encrypted")
|
|
71
|
+
user_card: Optional[str] = Field(None, description="User card ID")
|
|
72
|
+
user_status: Optional[bool] = Field(None, description="User status for advance search parameter")
|
|
73
|
+
user_operator_level_id: Optional[str] = Field("255", description="User operator level id for Advance Search parameter")
|
|
74
|
+
user_custom_field_1: Optional[str] = Field(None, description="Custom fields for advance search parameter support like search on unencrypted and exact search on encrypted")
|
|
75
|
+
user_custom_field_2: Optional[str] = Field(None, description="Custom fields for advance search parameter support like search on unencrypted and exact search on encrypted")
|
|
76
|
+
user_custom_field_3: Optional[str] = Field(None, description="Custom fields for advance search parameter support like search on unencrypted and exact search on encrypted")
|
|
77
|
+
user_custom_field_4: Optional[str] = Field(None, description="Custom fields for advance search parameter support like search on unencrypted and exact search on encrypted")
|
|
78
|
+
user_custom_field_5: Optional[str] = Field(None, description="Custom fields for advance search parameter support like search on unencrypted and exact search on encrypted")
|
|
79
|
+
user_custom_field_6: Optional[str] = Field(None, description="Custom fields for advance search parameter support like search on unencrypted and exact search on encrypted")
|
|
80
|
+
user_custom_field_7: Optional[str] = Field(None, description="Custom fields for advance search parameter support like search on unencrypted and exact search on encrypted")
|
|
81
|
+
user_custom_field_8: Optional[str] = Field(None, description="Custom fields for advance search parameter support like search on unencrypted and exact search on encrypted")
|
|
82
|
+
user_custom_field_9: Optional[str] = Field(None, description="Custom fields for advance search parameter support like search on unencrypted and exact search on encrypted")
|
|
83
|
+
user_custom_field_10: Optional[str] = Field(None, description="Custom fields for advance search parameter support like search on unencrypted and exact search on encrypted")
|
|
84
|
+
order_by: Optional[str] = Field("user_id:false", description="Order, will accept with format properties:false, for example if we want to order by name, it will be user_name:false. false = ASCENDING and true = DESCENDING")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class ExportCSVInput(BaseModel):
|
|
88
|
+
ids: Optional[List[Union[str, int]]] = Field(
|
|
89
|
+
default=None,
|
|
90
|
+
description="List of user IDs to export, or ['*'] to export all users"
|
|
91
|
+
)
|
|
92
|
+
search_text: Optional[str] = Field(
|
|
93
|
+
default=None,
|
|
94
|
+
description="Search text to find users for export. If provided, will search and export matching users."
|
|
95
|
+
)
|
|
96
|
+
limit: Optional[int] = Field(
|
|
97
|
+
default=50,
|
|
98
|
+
description="Maximum number of users to export (default: 50)"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class BulkEditUsersInput(BaseModel):
|
|
103
|
+
"""Input model for bulk editing users via PUT /api/users?adv=mode1."""
|
|
104
|
+
|
|
105
|
+
adv_criteria: Dict[str, Any] = Field(
|
|
106
|
+
..., description="Advanced criteria object to select target users (e.g., {'user_group_id': 4037})."
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# --- Optional change set (at least one must be provided) ---
|
|
110
|
+
new_user_group_id: Optional[Union[int, str]] = Field(
|
|
111
|
+
default=None, description="Destination user group id to assign to all matched users."
|
|
112
|
+
)
|
|
113
|
+
new_user_group: Optional[Dict[str, Any]] = Field(
|
|
114
|
+
default=None, description="Full user_group_id object to pass-through (e.g., {'id': '4036', 'name': 'ACE'})."
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
start_datetime: Optional[str] = Field(
|
|
118
|
+
default=None, description="New start datetime in ISO8601 (e.g., '2001-01-01T00:00:00')."
|
|
119
|
+
)
|
|
120
|
+
expiry_datetime: Optional[str] = Field(
|
|
121
|
+
default=None, description="New expiry datetime in ISO8601 (e.g., '2034-12-31T23:59:00')."
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
access_group_ids: Optional[List[Union[int, str]]] = Field(
|
|
125
|
+
default=None, description="List of Access Group IDs to assign (minimal form [{'id': <id>}])."
|
|
126
|
+
)
|
|
127
|
+
access_groups: Optional[List[Dict[str, Any]]] = Field(
|
|
128
|
+
default=None, description="Full AccessGroup objects to pass-through (supports accessLevels, users, etc.)."
|
|
129
|
+
)
|
|
130
|
+
clear_access_groups: bool = Field(
|
|
131
|
+
default=False, description="If true, sets 'access_groups': [] (ignores access_group_ids/access_groups)."
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# --- Utility flags ---
|
|
135
|
+
dry_run: bool = Field(default=False, description="If true, returns planned payload without calling the API.")
|
|
136
|
+
timeout: int = Field(default=30, ge=1, le=120, description="HTTP timeout seconds for the PUT request.")
|
|
137
|
+
|
|
138
|
+
@model_validator(mode="after")
|
|
139
|
+
def _validate_changes(self):
|
|
140
|
+
"""Ensure at least one change is requested and datetimes are consistent."""
|
|
141
|
+
has_group_change = self.new_user_group_id is not None or self.new_user_group is not None
|
|
142
|
+
has_period_change = self.start_datetime is not None or self.expiry_datetime is not None
|
|
143
|
+
has_access_change = self.clear_access_groups or self.access_group_ids is not None or self.access_groups is not None
|
|
144
|
+
|
|
145
|
+
if not (has_group_change or has_period_change or has_access_change):
|
|
146
|
+
raise ValueError(
|
|
147
|
+
"Provide at least one change among: new_user_group_id/new_user_group, "
|
|
148
|
+
"start_datetime/expiry_datetime, access_group_ids/access_groups/clear_access_groups."
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# Validate datetime ordering if both provided
|
|
152
|
+
if self.start_datetime and self.expiry_datetime:
|
|
153
|
+
def _parse(s: str) -> datetime:
|
|
154
|
+
s = s.strip()
|
|
155
|
+
if s.endswith("Z"):
|
|
156
|
+
s = s.replace("Z", "+00:00")
|
|
157
|
+
return datetime.fromisoformat(s)
|
|
158
|
+
try:
|
|
159
|
+
sdt = _parse(self.start_datetime)
|
|
160
|
+
edt = _parse(self.expiry_datetime)
|
|
161
|
+
if sdt >= edt:
|
|
162
|
+
raise ValueError("start_datetime must be earlier than expiry_datetime.")
|
|
163
|
+
except Exception as e:
|
|
164
|
+
raise ValueError(f"Invalid datetime format or ordering: {e}")
|
|
165
|
+
|
|
166
|
+
return self
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""
|
|
2
|
+
BioStar X MCP Server
|
|
3
|
+
Pure JSON-RPC STDIO server for MCP protocol
|
|
4
|
+
|
|
5
|
+
Compatible with:
|
|
6
|
+
- Claude Desktop
|
|
7
|
+
- Claude Code
|
|
8
|
+
- Any MCP-compatible client
|
|
9
|
+
"""
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
import logging
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
# Setup logging to stderr (MCP uses stdout for protocol)
|
|
17
|
+
logging.basicConfig(
|
|
18
|
+
level=logging.INFO,
|
|
19
|
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
|
20
|
+
stream=sys.stderr
|
|
21
|
+
)
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
from .config import Config
|
|
25
|
+
from .session import BioStarSession
|
|
26
|
+
from .tool_manager import ToolManager
|
|
27
|
+
from .handlers import (
|
|
28
|
+
AuthHandler,
|
|
29
|
+
UserHandler,
|
|
30
|
+
DoorHandler,
|
|
31
|
+
AccessHandler,
|
|
32
|
+
DeviceHandler,
|
|
33
|
+
EventHandler,
|
|
34
|
+
AuditHandler,
|
|
35
|
+
CardsHandler,
|
|
36
|
+
NavigationHandler,
|
|
37
|
+
FileHandler,
|
|
38
|
+
LogHandler,
|
|
39
|
+
OccupancyHandler,
|
|
40
|
+
HelpWebHandler
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class BioStarMCPServer:
|
|
45
|
+
"""BioStar X MCP Server implementation."""
|
|
46
|
+
|
|
47
|
+
def __init__(self):
|
|
48
|
+
logger.info("Initializing Suprema BioStar MCP Server...")
|
|
49
|
+
|
|
50
|
+
self.config = Config()
|
|
51
|
+
|
|
52
|
+
# Validate required credentials at startup
|
|
53
|
+
errors = self.config.validate_required()
|
|
54
|
+
if errors:
|
|
55
|
+
logger.error("=" * 60)
|
|
56
|
+
logger.error("CONFIGURATION ERROR - Missing required settings")
|
|
57
|
+
logger.error("=" * 60)
|
|
58
|
+
for err in errors:
|
|
59
|
+
logger.error(f" ✗ {err}")
|
|
60
|
+
logger.error("")
|
|
61
|
+
logger.error("Set environment variables in Claude Desktop config:")
|
|
62
|
+
logger.error("")
|
|
63
|
+
logger.error(' "mcpServers": {')
|
|
64
|
+
logger.error(' "suprema-biostar": {')
|
|
65
|
+
logger.error(' "command": "uvx",')
|
|
66
|
+
logger.error(' "args": ["suprema-biostar-mcp"],')
|
|
67
|
+
logger.error(' "env": {')
|
|
68
|
+
logger.error(' "BIOSTAR_URL": "https://your-biostar-server",')
|
|
69
|
+
logger.error(' "BIOSTAR_USERNAME": "admin",')
|
|
70
|
+
logger.error(' "BIOSTAR_PASSWORD": "your-password"')
|
|
71
|
+
logger.error(' }')
|
|
72
|
+
logger.error(' }')
|
|
73
|
+
logger.error(' }')
|
|
74
|
+
logger.error("")
|
|
75
|
+
logger.error("Config file location:")
|
|
76
|
+
logger.error(" Windows: %APPDATA%\\Claude\\claude_desktop_config.json")
|
|
77
|
+
logger.error(" macOS: ~/Library/Application Support/Claude/claude_desktop_config.json")
|
|
78
|
+
logger.error("=" * 60)
|
|
79
|
+
raise RuntimeError(f"Missing required configuration: {', '.join(errors)}")
|
|
80
|
+
|
|
81
|
+
self.session = BioStarSession(self.config)
|
|
82
|
+
self.tool_manager = ToolManager()
|
|
83
|
+
|
|
84
|
+
# Initialize all handlers
|
|
85
|
+
self.handlers = {
|
|
86
|
+
"auth": AuthHandler(self.session),
|
|
87
|
+
"users": UserHandler(self.session),
|
|
88
|
+
"doors": DoorHandler(self.session),
|
|
89
|
+
"access": AccessHandler(self.session),
|
|
90
|
+
"devices": DeviceHandler(self.session),
|
|
91
|
+
"events": EventHandler(self.session),
|
|
92
|
+
"audit": AuditHandler(self.session),
|
|
93
|
+
"cards": CardsHandler(self.session),
|
|
94
|
+
"navigation": NavigationHandler(), # No session needed
|
|
95
|
+
"files": FileHandler(), # No session needed
|
|
96
|
+
"logs": LogHandler(self.session),
|
|
97
|
+
"occupancy": OccupancyHandler(self.session),
|
|
98
|
+
"help": HelpWebHandler(cache_ttl=self.config.docs_cache_ttl),
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
# Load all tool categories
|
|
102
|
+
categories = [
|
|
103
|
+
"auth", "users", "doors", "access", "devices",
|
|
104
|
+
"events", "audit", "cards", "navigation",
|
|
105
|
+
"files", "logs", "occupancy", "help"
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
for category in categories:
|
|
109
|
+
success = self.tool_manager.load_category(category)
|
|
110
|
+
logger.info(f"Loading category '{category}': {'OK' if success else 'FAILED'}")
|
|
111
|
+
|
|
112
|
+
# Verify tools are loaded
|
|
113
|
+
all_tools = self.tool_manager.get_active_tools()
|
|
114
|
+
logger.info(f"Total tools loaded: {len(all_tools)}")
|
|
115
|
+
logger.info(f"Connected to: {self.config.biostar_url}")
|
|
116
|
+
logger.info("Suprema BioStar MCP Server initialized successfully")
|
|
117
|
+
|
|
118
|
+
async def handle_initialize(self, request):
|
|
119
|
+
"""Handle MCP initialize request."""
|
|
120
|
+
logger.info("Handling initialize request")
|
|
121
|
+
|
|
122
|
+
response = {
|
|
123
|
+
"jsonrpc": "2.0",
|
|
124
|
+
"id": request.get("id", 0),
|
|
125
|
+
"result": {
|
|
126
|
+
"protocolVersion": "2024-11-05",
|
|
127
|
+
"capabilities": {
|
|
128
|
+
"tools": {
|
|
129
|
+
"listChanged": True
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
"serverInfo": {
|
|
133
|
+
"name": "suprema-biostar-mcp",
|
|
134
|
+
"version": "1.0.0"
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
print(json.dumps(response), flush=True)
|
|
140
|
+
logger.info("Sent initialize response")
|
|
141
|
+
|
|
142
|
+
# Send tools/listChanged notification
|
|
143
|
+
await asyncio.sleep(0.1)
|
|
144
|
+
notification = {
|
|
145
|
+
"jsonrpc": "2.0",
|
|
146
|
+
"method": "notifications/tools/listChanged",
|
|
147
|
+
"params": {}
|
|
148
|
+
}
|
|
149
|
+
print(json.dumps(notification), flush=True)
|
|
150
|
+
|
|
151
|
+
async def handle_list_tools(self, request):
|
|
152
|
+
"""Handle MCP tools/list request."""
|
|
153
|
+
logger.info("Handling tools/list request")
|
|
154
|
+
|
|
155
|
+
tools = self.tool_manager.get_active_tools()
|
|
156
|
+
tools_data = []
|
|
157
|
+
|
|
158
|
+
for tool in tools:
|
|
159
|
+
tools_data.append({
|
|
160
|
+
"name": tool.name,
|
|
161
|
+
"description": tool.description,
|
|
162
|
+
"inputSchema": tool.inputSchema
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
response = {
|
|
166
|
+
"jsonrpc": "2.0",
|
|
167
|
+
"id": request.get("id", 0),
|
|
168
|
+
"result": {
|
|
169
|
+
"tools": tools_data
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
print(json.dumps(response), flush=True)
|
|
174
|
+
logger.info(f"Returned {len(tools_data)} tools")
|
|
175
|
+
|
|
176
|
+
async def handle_call_tool(self, request):
|
|
177
|
+
"""Handle MCP tools/call request."""
|
|
178
|
+
params = request.get("params", {})
|
|
179
|
+
tool_name = params.get("name")
|
|
180
|
+
arguments = params.get("arguments", {})
|
|
181
|
+
|
|
182
|
+
logger.info(f"Handling tools/call for: {tool_name}")
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
# Get the category for this tool
|
|
186
|
+
category = self.tool_manager.get_tool_category(tool_name)
|
|
187
|
+
handler = self.handlers.get(category)
|
|
188
|
+
|
|
189
|
+
# Special handling for help tools
|
|
190
|
+
help_tool_mapping = {
|
|
191
|
+
"search-biostar-docs": ("help", "search_help"),
|
|
192
|
+
"get-docs-page": ("help", "get_docs_page"),
|
|
193
|
+
"get-tool-help": ("help", "get_tool_help"),
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
result = None
|
|
197
|
+
|
|
198
|
+
if tool_name in help_tool_mapping:
|
|
199
|
+
cat, method_name = help_tool_mapping[tool_name]
|
|
200
|
+
handler = self.handlers.get(cat)
|
|
201
|
+
if handler and hasattr(handler, method_name):
|
|
202
|
+
method_func = getattr(handler, method_name)
|
|
203
|
+
result = await method_func(arguments)
|
|
204
|
+
else:
|
|
205
|
+
raise ValueError(f"Handler {cat} has no method: {method_name}")
|
|
206
|
+
elif handler:
|
|
207
|
+
# Convert tool-name to method_name (e.g., get-users -> get_users)
|
|
208
|
+
method_name = tool_name.replace("-", "_")
|
|
209
|
+
if hasattr(handler, method_name):
|
|
210
|
+
method_func = getattr(handler, method_name)
|
|
211
|
+
result = await method_func(arguments)
|
|
212
|
+
else:
|
|
213
|
+
raise ValueError(f"Handler has no method: {method_name}")
|
|
214
|
+
else:
|
|
215
|
+
raise ValueError(f"No handler for tool: {tool_name}")
|
|
216
|
+
|
|
217
|
+
# Convert result to proper MCP format
|
|
218
|
+
if isinstance(result, list):
|
|
219
|
+
content = []
|
|
220
|
+
for item in result:
|
|
221
|
+
if hasattr(item, 'type') and hasattr(item, 'text'):
|
|
222
|
+
content.append({
|
|
223
|
+
"type": item.type,
|
|
224
|
+
"text": item.text
|
|
225
|
+
})
|
|
226
|
+
else:
|
|
227
|
+
content.append({"type": "text", "text": str(item)})
|
|
228
|
+
elif hasattr(result, 'type') and hasattr(result, 'text'):
|
|
229
|
+
content = [{"type": result.type, "text": result.text}]
|
|
230
|
+
else:
|
|
231
|
+
content = [{"type": "text", "text": str(result)}]
|
|
232
|
+
|
|
233
|
+
response = {
|
|
234
|
+
"jsonrpc": "2.0",
|
|
235
|
+
"id": request.get("id", 0),
|
|
236
|
+
"result": {
|
|
237
|
+
"content": content
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
# Record tool usage
|
|
242
|
+
self.tool_manager.record_tool_usage(tool_name)
|
|
243
|
+
|
|
244
|
+
except Exception as e:
|
|
245
|
+
logger.error(f"Error executing tool {tool_name}: {e}", exc_info=True)
|
|
246
|
+
response = {
|
|
247
|
+
"jsonrpc": "2.0",
|
|
248
|
+
"id": request.get("id", 0),
|
|
249
|
+
"error": {
|
|
250
|
+
"code": -32603,
|
|
251
|
+
"message": f"Internal error: {str(e)}"
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
print(json.dumps(response), flush=True)
|
|
256
|
+
|
|
257
|
+
async def run(self):
|
|
258
|
+
"""Main server loop - reads from stdin, writes to stdout."""
|
|
259
|
+
logger.info("Starting BioStar X MCP Server...")
|
|
260
|
+
|
|
261
|
+
while True:
|
|
262
|
+
try:
|
|
263
|
+
# Read input from stdin
|
|
264
|
+
line = await asyncio.get_event_loop().run_in_executor(
|
|
265
|
+
None, sys.stdin.readline
|
|
266
|
+
)
|
|
267
|
+
if not line:
|
|
268
|
+
logger.info("Stdin closed, shutting down")
|
|
269
|
+
break
|
|
270
|
+
|
|
271
|
+
# Parse JSON-RPC request
|
|
272
|
+
request = json.loads(line.strip())
|
|
273
|
+
method = request.get("method")
|
|
274
|
+
|
|
275
|
+
logger.debug(f"Received request: {method}")
|
|
276
|
+
|
|
277
|
+
# Handle different MCP methods
|
|
278
|
+
if method == "initialize":
|
|
279
|
+
await self.handle_initialize(request)
|
|
280
|
+
elif method == "tools/list":
|
|
281
|
+
await self.handle_list_tools(request)
|
|
282
|
+
elif method == "tools/call":
|
|
283
|
+
await self.handle_call_tool(request)
|
|
284
|
+
elif method == "notifications/initialized":
|
|
285
|
+
logger.info("Received notifications/initialized")
|
|
286
|
+
continue
|
|
287
|
+
elif method == "notifications/cancelled":
|
|
288
|
+
logger.info("Received notifications/cancelled")
|
|
289
|
+
continue
|
|
290
|
+
elif method == "resources/list":
|
|
291
|
+
response = {
|
|
292
|
+
"jsonrpc": "2.0",
|
|
293
|
+
"id": request.get("id", 0),
|
|
294
|
+
"result": {"resources": []}
|
|
295
|
+
}
|
|
296
|
+
print(json.dumps(response), flush=True)
|
|
297
|
+
elif method == "prompts/list":
|
|
298
|
+
response = {
|
|
299
|
+
"jsonrpc": "2.0",
|
|
300
|
+
"id": request.get("id", 0),
|
|
301
|
+
"result": {"prompts": []}
|
|
302
|
+
}
|
|
303
|
+
print(json.dumps(response), flush=True)
|
|
304
|
+
else:
|
|
305
|
+
response = {
|
|
306
|
+
"jsonrpc": "2.0",
|
|
307
|
+
"id": request.get("id", 0),
|
|
308
|
+
"error": {
|
|
309
|
+
"code": -32601,
|
|
310
|
+
"message": f"Method not found: {method}"
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
print(json.dumps(response), flush=True)
|
|
314
|
+
|
|
315
|
+
except json.JSONDecodeError as e:
|
|
316
|
+
logger.error(f"JSON decode error: {e}")
|
|
317
|
+
continue
|
|
318
|
+
except Exception as e:
|
|
319
|
+
logger.error(f"Unexpected error: {e}", exc_info=True)
|
|
320
|
+
continue
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
async def async_main():
|
|
324
|
+
"""Async main entry point."""
|
|
325
|
+
server = BioStarMCPServer()
|
|
326
|
+
await server.run()
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def main():
|
|
330
|
+
"""Synchronous main entry point for CLI."""
|
|
331
|
+
asyncio.run(async_main())
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
if __name__ == "__main__":
|
|
335
|
+
main()
|