Skip to content

Commit 249156d

Browse files
authored
wip: mcp (#10)
1 parent c97f0b7 commit 249156d

File tree

6 files changed

+327
-1
lines changed

6 files changed

+327
-1
lines changed

Makefile

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ test:
1212
uv run pytest
1313

1414
test-tools:
15-
uv run pytest stackone_ai
15+
uv run pytest tests
1616

1717
test-examples:
1818
uv run pytest examples
@@ -27,3 +27,7 @@ docs-serve:
2727
docs-build:
2828
uv run scripts/build_docs.py
2929
uv run mkdocs build
30+
31+
mcp-inspector:
32+
uv sync --all-extras
33+
npx @modelcontextprotocol/inspector stackmcp

examples/mcp_server.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
"""
2+
This package can also be used as a Model Context Protocol (MCP) server.
3+
4+
To add this server to and MCP client like Claude Code, use:
5+
6+
```bash
7+
# install the package
8+
uv pip install stackone-ai
9+
10+
# add the server to Claude Code
11+
claude mcp add stackone uv stackmcp ["--api-key", "<your-api-key>"]
12+
```
13+
14+
This implementation is a work in progress and will likely change dramatically in the near future.
15+
"""

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,12 @@ dependencies = [
1919
"pydantic>=2.10.6",
2020
"requests>=2.32.3",
2121
"langchain-core>=0.1.0",
22+
"mcp[cli]>=1.3.0",
2223
]
2324

25+
[project.scripts]
26+
stackmcp = "stackone_ai.server:cli"
27+
2428
[build-system]
2529
requires = ["hatchling"]
2630
build-backend = "hatchling.build"

stackone_ai/server.py

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
import argparse
2+
import asyncio
3+
import logging
4+
import os
5+
import sys
6+
from typing import Any, TypeVar
7+
8+
import mcp.types as types
9+
from mcp.server import NotificationOptions, Server
10+
from mcp.server.models import InitializationOptions
11+
from mcp.server.stdio import stdio_server
12+
from mcp.shared.exceptions import McpError
13+
from mcp.types import EmbeddedResource, ErrorData, ImageContent, TextContent, Tool
14+
from pydantic import ValidationError
15+
16+
from stackone_ai import StackOneToolSet
17+
from stackone_ai.models import StackOneAPIError, StackOneError
18+
19+
# Set up logging
20+
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
21+
logger = logging.getLogger("stackone.mcp")
22+
23+
app: Server = Server("stackone-ai")
24+
toolset: StackOneToolSet | None = None
25+
26+
NO_ACCOUNT_ID_PREFIXES = [
27+
"stackone_",
28+
]
29+
30+
# Type variables for function annotations
31+
T = TypeVar("T")
32+
R = TypeVar("R")
33+
34+
35+
def tool_needs_account_id(tool_name: str) -> bool:
36+
for prefix in NO_ACCOUNT_ID_PREFIXES:
37+
if tool_name.startswith(prefix):
38+
return False
39+
40+
# By default, assume all other tools need account_id
41+
return True
42+
43+
44+
@app.list_tools() # type: ignore[misc]
45+
async def list_tools() -> list[Tool]:
46+
"""List all available StackOne tools as MCP tools."""
47+
if not toolset:
48+
logger.error("Toolset not initialized")
49+
raise McpError(
50+
ErrorData(
51+
code=types.INTERNAL_ERROR,
52+
message="Toolset not initialized, please check your STACKONE_API_KEY.",
53+
)
54+
)
55+
56+
try:
57+
mcp_tools: list[Tool] = []
58+
tools = toolset.get_tools()
59+
# Convert to a list if it's not already iterable in the expected way
60+
tool_list = list(tools.tools) if hasattr(tools, "tools") else []
61+
62+
for tool in tool_list:
63+
# Convert StackOne tool parameters to MCP schema
64+
properties = {}
65+
required = []
66+
67+
# Add account_id parameter only for tools that need it
68+
if tool_needs_account_id(tool.name):
69+
properties["account_id"] = {
70+
"type": "string",
71+
"description": "The StackOne account ID to use for this tool call",
72+
}
73+
74+
for name, details in tool.parameters.properties.items():
75+
if isinstance(details, dict):
76+
prop = {
77+
"type": details.get("type", "string"),
78+
"description": details.get("description", ""),
79+
}
80+
if not details.get("nullable", False):
81+
required.append(name)
82+
properties[name] = prop
83+
84+
schema = {"type": "object", "properties": properties}
85+
if required:
86+
schema["required"] = required
87+
88+
mcp_tools.append(Tool(name=tool.name, description=tool.description, inputSchema=schema))
89+
90+
logger.info(f"Listed {len(mcp_tools)} tools")
91+
return mcp_tools
92+
except Exception as e:
93+
logger.error(f"Error listing tools: {str(e)}", exc_info=True)
94+
raise McpError(
95+
ErrorData(
96+
code=types.INTERNAL_ERROR,
97+
message=f"Error listing tools: {str(e)}",
98+
)
99+
) from e
100+
101+
102+
@app.call_tool() # type: ignore[misc]
103+
async def call_tool(
104+
name: str, arguments: dict[str, Any]
105+
) -> list[TextContent | ImageContent | EmbeddedResource]:
106+
"""Execute a StackOne tool and return its result."""
107+
if not toolset:
108+
logger.error("Toolset not initialized")
109+
raise McpError(
110+
ErrorData(
111+
code=types.INTERNAL_ERROR,
112+
message="Server configuration error: Toolset not initialized",
113+
)
114+
)
115+
116+
try:
117+
tool = toolset.get_tool(name)
118+
if not tool:
119+
logger.warning(f"Tool not found: {name}")
120+
raise McpError(
121+
ErrorData(
122+
code=types.INVALID_PARAMS,
123+
message=f"Tool not found: {name}",
124+
)
125+
)
126+
127+
if "account_id" in arguments:
128+
tool.set_account_id(arguments.pop("account_id"))
129+
130+
if tool_needs_account_id(name) and tool.get_account_id() is None:
131+
logger.warning(f"Tool {name} needs account_id but none provided")
132+
raise McpError(
133+
ErrorData(
134+
code=types.INVALID_PARAMS,
135+
message=f"Tool {name} needs account_id but none provided",
136+
)
137+
)
138+
139+
result = tool.execute(arguments)
140+
return [TextContent(type="text", text=str(result))]
141+
142+
except ValidationError as e:
143+
logger.warning(f"Invalid parameters for tool {name}: {str(e)}")
144+
raise McpError(
145+
ErrorData(
146+
code=types.INVALID_PARAMS,
147+
message=f"Invalid parameters for tool {name}: {str(e)}",
148+
)
149+
) from e
150+
except StackOneAPIError as e:
151+
logger.error(f"API error: {str(e)}")
152+
raise McpError(
153+
ErrorData(
154+
code=types.INTERNAL_ERROR,
155+
message=f"API error: {str(e)}",
156+
)
157+
) from e
158+
except StackOneError as e:
159+
logger.error(f"Error: {str(e)}")
160+
raise McpError(
161+
ErrorData(
162+
code=types.INTERNAL_ERROR,
163+
message=f"Error: {str(e)}",
164+
)
165+
) from e
166+
except Exception as e:
167+
logger.error(f"Unexpected error: {str(e)}", exc_info=True)
168+
raise McpError(
169+
ErrorData(
170+
code=types.INTERNAL_ERROR,
171+
message="An unexpected error occurred. Please try again later.",
172+
)
173+
) from e
174+
175+
176+
async def main(api_key: str | None = None) -> None:
177+
"""Run the MCP server."""
178+
179+
if not api_key:
180+
api_key = os.getenv("STACKONE_API_KEY")
181+
if not api_key:
182+
raise ValueError("STACKONE_API_KEY not found in environment variables")
183+
184+
global toolset
185+
toolset = StackOneToolSet(api_key=api_key)
186+
logger.info("StackOne toolset initialized successfully")
187+
188+
async with stdio_server() as (read_stream, write_stream):
189+
await app.run(
190+
read_stream,
191+
write_stream,
192+
InitializationOptions(
193+
server_name="stackone-ai",
194+
server_version="0.1.0",
195+
capabilities=app.get_capabilities(
196+
notification_options=NotificationOptions(),
197+
experimental_capabilities={},
198+
),
199+
),
200+
)
201+
202+
203+
def cli() -> None:
204+
"""CLI entry point for the MCP server."""
205+
parser = argparse.ArgumentParser(description="StackOne AI MCP Server")
206+
parser.add_argument("--api-key", help="StackOne API key (can also be set via STACKONE_API_KEY env var)")
207+
parser.add_argument(
208+
"--log-level",
209+
default="INFO",
210+
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
211+
help="Set the logging level",
212+
)
213+
args = parser.parse_args()
214+
215+
logger.setLevel(args.log_level)
216+
217+
try:
218+
asyncio.run(main(args.api_key))
219+
except Exception as e:
220+
logger.critical(f"Failed to start server: {str(e)}", exc_info=True)
221+
sys.exit(1)
222+
223+
224+
if __name__ == "__main__":
225+
cli()

stackone_ai/toolset.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,22 @@ def _matches_filter(self, tool_name: str, filter_pattern: str | list[str]) -> bo
106106

107107
return matches_positive and not matches_negative
108108

109+
def get_tool(self, name: str, *, account_id: str | None = None) -> StackOneTool | None:
110+
"""Get a specific tool by name
111+
112+
Args:
113+
name: Name of the tool to retrieve
114+
account_id: Optional account ID override. If not provided, uses the one from initialization
115+
116+
Returns:
117+
The tool if found, None otherwise
118+
119+
Raises:
120+
ToolsetLoadError: If there is an error loading the tools
121+
"""
122+
tools = self.get_tools(name, account_id=account_id)
123+
return tools.get_tool(name)
124+
109125
def get_tools(
110126
self, filter_pattern: str | list[str] | None = None, *, account_id: str | None = None
111127
) -> Tools:

uv.lock

Lines changed: 62 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)