AgentToolStore / client /src /toolstore /mcp_server.py
ToolStore Agent
fix(management): shift UI from 'create from scratch' to 'connect to existing / register from file'
a876f68
Raw
History Blame Contribute Delete
15.6 kB
"""
ToolStore as an MCP server.
Exposes all indexed tools (api, mcp, skill) as MCP tools/resources/prompts
so any MCP-compatible host (Claude Desktop, VS Code, etc.) can connect and
use the entire ToolStore catalog.
Implements the full MCP server feature set:
- tools/list, tools/call
- resources/list, resources/read
- prompts/list, prompts/get
- notifications for list changes
Supports stdio transport natively, plus a FastAPI integration for SSE.
"""
from __future__ import annotations
import json
import sys
import threading
from typing import Dict, List, Any, Optional, Callable
from toolstore.schema_converter import (
toolstore_to_mcp, flatten_mcp_content, bulk_to_openai
)
from toolstore.skill_manager import get_skill_manager
# ---------------------------------------------------------------------------
# ToolStore MCP Server (protocol logic only — transport agnostic)
# ---------------------------------------------------------------------------
class ToolStoreMCPServer:
"""Protocol handler for ToolStore-as-MCP-server.
This is transport-agnostic. Hook it up to stdio or an SSE endpoint.
"""
PROTOCOL_VERSION = "2024-11-05"
SERVER_NAME = "toolstore"
SERVER_VERSION = "2.0.0"
def __init__(self, index_manager, config_manager, skill_manager=None):
self._index = index_manager
self._config = config_manager
self._skills = skill_manager or get_skill_manager()
self._request_id = 0
self._lock = threading.Lock()
self._on_send: Optional[Callable[[Dict[str, Any]], None]] = None
# Track whether capabilities were declared
self._declared_caps: Dict[str, Any] = {}
# ------------------------------------------------------------------
# Transport hook
# ------------------------------------------------------------------
def set_send_callback(self, callback: Callable[[Dict[str, Any]], None]) -> None:
"""Register the function used to send messages back to the client."""
self._on_send = callback
def handle_message(self, raw: Dict[str, Any]) -> None:
"""Process an incoming JSON-RPC message from the client."""
method = raw.get("method")
rid = raw.get("id")
params = raw.get("params", {})
# Notification (no id)
if rid is None:
self._handle_notification(method, params)
return
# Request
try:
result = self._dispatch(method, params)
self._send({"jsonrpc": "2.0", "id": rid, "result": result})
except Exception as exc:
self._send({
"jsonrpc": "2.0", "id": rid,
"error": {"code": -32603, "message": str(exc)},
})
# ------------------------------------------------------------------
# Dispatch
# ------------------------------------------------------------------
def _dispatch(self, method: str, params: Any) -> Any:
handlers = {
"initialize": self._handle_initialize,
"tools/list": self._handle_tools_list,
"tools/call": self._handle_tools_call,
"resources/list": self._handle_resources_list,
"resources/read": self._handle_resources_read,
"prompts/list": self._handle_prompts_list,
"prompts/get": self._handle_prompts_get,
}
handler = handlers.get(method)
if handler is None:
raise RuntimeError(f"Unknown method: {method}")
return handler(params)
def _handle_notification(self, method: str, params: Any) -> None:
if method == "notifications/initialized":
pass # client is ready
# Silently ignore others
# ------------------------------------------------------------------
# initialize
# ------------------------------------------------------------------
def _handle_initialize(self, params: Dict[str, Any]) -> Dict[str, Any]:
self._declared_caps = {
"tools": {"listChanged": True},
"resources": {"listChanged": False},
"prompts": {"listChanged": False},
}
return {
"protocolVersion": self.PROTOCOL_VERSION,
"capabilities": self._declared_caps,
"serverInfo": {
"name": self.SERVER_NAME,
"version": self.SERVER_VERSION,
},
}
# ------------------------------------------------------------------
# tools/list
# ------------------------------------------------------------------
def _handle_tools_list(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Return all indexed tools in MCP format."""
tools = []
all_tools = self._index.index_data.get("tools", {})
for _name, tool_def in all_tools.items():
tools.append(toolstore_to_mcp(tool_def))
# Also include live MCP-scanned tools not yet in index? For now,
# only indexed tools are exposed.
return {"tools": tools}
# ------------------------------------------------------------------
# tools/call
# ------------------------------------------------------------------
def _handle_tools_call(self, params: Dict[str, Any]) -> Dict[str, Any]:
tool_name = params["name"]
arguments = params.get("arguments", {})
tool_def = self._index.get_tool(tool_name)
if not tool_def:
return {
"content": [{"type": "text", "text": f"Tool '{tool_name}' not found"}],
"isError": True,
}
# Dispatch by type
tool_type = tool_def.get("type", "api")
try:
if tool_type == "skill":
return self._execute_skill(tool_def, arguments)
elif tool_type == "api":
return self._execute_api(tool_def, arguments)
elif tool_type == "mcp":
return self._execute_mcp(tool_def, arguments)
else:
return {
"content": [{"type": "text",
"text": f"Unknown tool type: {tool_type}"}],
"isError": True,
}
except Exception as exc:
return {
"content": [{"type": "text", "text": str(exc)}],
"isError": True,
}
def _execute_api(self, tool_def: Dict[str, Any],
args: Dict[str, Any]) -> Dict[str, Any]:
import httpx
url = tool_def["endpoint"]
method = tool_def.get("method", "GET").upper()
# Path param substitution
final_url = url
for k, v in args.items():
if f"{{{k}}}" in final_url:
final_url = final_url.replace(f"{{{k}}}", str(v))
request_params = {k: v for k, v in args.items()
if f"{{{k}}}" not in url}
if method == "GET":
resp = httpx.get(final_url, params=request_params, timeout=30.0)
else:
resp = httpx.request(method, final_url, json=request_params,
timeout=30.0)
try:
body = json.dumps(resp.json(), indent=2)
except Exception:
body = resp.text
return {"content": [{"type": "text", "text": body}]}
def _execute_mcp(self, tool_def: Dict[str, Any],
args: Dict[str, Any]) -> Dict[str, Any]:
from toolstore.mcp_client import get_client
server_name = tool_def.get("mcp_server")
if not server_name:
return {"content": [{"type": "text",
"text": "Tool missing mcp_server"}],
"isError": True}
servers = self._config.get_mcp_servers()
config = servers.get(server_name)
if not config:
return {"content": [{"type": "text",
"text": f"MCP server '{server_name}' "
"not configured"}],
"isError": True}
client = get_client(server_name, config)
result = client.call_tool(tool_def["name"], args)
return result # already has 'content' and 'isError'
def _execute_skill(self, tool_def: Dict[str, Any],
args: Dict[str, Any]) -> Dict[str, Any]:
"""Execute a skill tool. Args: load/files/file/run, script?"""
action = args.get("action", "load")
skill_name = tool_def["name"]
# Ensure the skill manager has this skill loaded
if not self._skills.get_skill(skill_name):
from toolstore.skill_manager import SkillManager
dirs = self._config.get_skill_dirs()
if dirs:
fresh = SkillManager(dirs)
fresh.scan()
self._skills = fresh
if action == "load":
body = self._skills.get_skill_body(skill_name)
if body is None:
return {"content": [{"type": "text",
"text": f"Skill '{skill_name}' not loaded"}],
"isError": True}
return {"content": [{"type": "text", "text": body}]}
elif action == "files":
sd = self._skills.get_skill(skill_name)
if not sd:
return {"content": [{"type": "text",
"text": f"Skill '{skill_name}' not found"}],
"isError": True}
flist = [str(f) for f in sd.list_files()]
return {"content": [{"type": "text",
"text": "\n".join(flist) or "(no extra files)"}]}
elif action == "file":
file_path = args.get("file_path", "")
if not file_path:
return {"content": [{"type": "text",
"text": "file_path is required for "
"action='file'"}],
"isError": True}
content = self._skills.get_skill_file(skill_name, file_path)
if content is None:
return {"content": [{"type": "text",
"text": f"File '{file_path}' not found "
f"in skill '{skill_name}'"}],
"isError": True}
return {"content": [{"type": "text", "text": content}]}
elif action == "run":
script = args.get("script", "")
if not script:
return {"content": [{"type": "text",
"text": "'script' argument required for action='run'"}],
"isError": True}
output = self._skills.run_skill_script(skill_name, script)
return {"content": [{"type": "text", "text": output}]}
else:
return {"content": [{"type": "text",
"text": f"Unknown action: {action}"}],
"isError": True}
# ------------------------------------------------------------------
# resources/list
# ------------------------------------------------------------------
def _handle_resources_list(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Expose skill SKILL.md files as resources."""
resources = []
for name, sd in self._skills._skills.items():
resources.append({
"uri": f"skill://{name}/SKILL.md",
"name": f"Skill: {name}",
"description": sd.description,
"mimeType": "text/markdown",
})
return {"resources": resources}
# ------------------------------------------------------------------
# resources/read
# ------------------------------------------------------------------
def _handle_resources_read(self, params: Dict[str, Any]) -> Dict[str, Any]:
uri = params["uri"]
# Parse skill:// URIs
if uri.startswith("skill://") and uri.endswith("/SKILL.md"):
name = uri[len("skill://"):-len("/SKILL.md")]
body = self._skills.get_skill_body(name)
if body is not None:
return {
"contents": [{
"uri": uri,
"mimeType": "text/markdown",
"text": body,
}],
}
# Fallback: read raw from any indexed tool's schema
for tname, tool_def in self._index.index_data.get("tools", {}).items():
if uri == f"tool://{tname}":
return {
"contents": [{
"uri": uri,
"mimeType": "application/json",
"text": json.dumps(tool_def, indent=2),
}],
}
raise RuntimeError(f"Resource not found: {uri}")
# ------------------------------------------------------------------
# prompts/list
# ------------------------------------------------------------------
def _handle_prompts_list(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Expose each loaded skill's body as an available prompt."""
prompts = []
for name, sd in self._skills._skills.items():
prompts.append({
"name": name,
"description": sd.description or f"Skill: {name}",
"arguments": [],
})
return {"prompts": prompts}
# ------------------------------------------------------------------
# prompts/get
# ------------------------------------------------------------------
def _handle_prompts_get(self, params: Dict[str, Any]) -> Dict[str, Any]:
name = params["name"]
body = self._skills.get_skill_body(name)
if body is None:
raise RuntimeError(f"Prompt not found: {name}")
return {
"description": f"Skill instructions for {name}",
"messages": [
{"role": "user", "content": {"type": "text", "text": body}},
],
}
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _send(self, message: Dict[str, Any]) -> None:
if self._on_send:
self._on_send(message)
# ------------------------------------------------------------------
# Stdio runner (for standalone mode)
# ------------------------------------------------------------------
def run_stdio(self) -> None:
"""Run the server on stdin/stdout (e.g. for Claude Desktop)."""
self.set_send_callback(
lambda msg: sys.stdout.write(json.dumps(msg) + "\n")
)
# Debug logging to stderr
print(f"ToolStore MCP Server v{self.SERVER_VERSION} ready",
file=sys.stderr)
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
msg = json.loads(line)
except json.JSONDecodeError:
continue
self.handle_message(msg)
sys.stdout.flush()