Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Thank you for your interest in contributing to the SingleStore MCP Server! This

Before you begin, ensure you have the following installed:

- **Python 3.10+** (recommended: Python 3.11)
- **Python 3.11+**
- **[uv](https://docs.astral.sh/uv/)** - Modern Python package manager
- **Git** - Version control
- **SingleStore account** - For testing database connectivity
Expand Down
17 changes: 15 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
FROM python:3.11-slim-bookworm
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/


# Add ARGs for build-time configuration with defaults
ARG TRANSPORT=streamable-http
ARG HOST=0.0.0.0
ARG PORT=8010

# Set ENV vars from ARGs to be available at runtime
ENV TRANSPORT=${TRANSPORT}
ENV HOST=${HOST}
ENV PORT=${PORT}


# Copy the project into the image
ADD . /app

Expand All @@ -10,6 +22,7 @@ WORKDIR /app
RUN uv sync --locked --no-cache

# Expose the port the MCP server runs on
EXPOSE 8000
EXPOSE ${PORT}

CMD ["uv", "run", "src/main.py", "start", "--transport", "stdio", "--host", "0.0.0.0"]
# Use the environment variables in the CMD instruction
CMD uv run src/main.py start --transport ${TRANSPORT} --host ${HOST}
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ With MCP, you can use Claude Desktop, Claude Code, Cursor, or any compatible MCP

## Requirements

- Python >= v3.10.0
- Python >= v3.11.0
- [uvx](https://docs.astral.sh/uv/guides/tools/) installed on your python environment
- VS Code, Cursor, Windsurf, Claude Desktop, Claude Code, Goose or any other MCP client

Expand Down
10 changes: 6 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ name = "singlestore_mcp_server"
dynamic = ["version"]
description = "SingleStore MCP server"
readme = "README.md"
requires-python = ">=3.10"
requires-python = ">=3.11"
dependencies = [
"mcp>=1.10.1",
"mcp>=1.17.0",
"nbformat>=5.10.4",
"pydantic-settings>=2.9.1",
"segment-analytics-python>=2.3.4",
Expand All @@ -14,6 +14,8 @@ dependencies = [
"singlestoredb>=1.15.1",
"pre-commit>=4.2.0",
"pytest-asyncio>=1.1.0",
"fastmcp==2.13.0.1",
"pyjwt>=2.10.1",
]

[project.scripts]
Expand All @@ -34,8 +36,8 @@ packages = ["src"]
line-length = 88
indent-width = 4

# Assume Python 3.10+
target-version = "py310"
# Assume Python 3.11+
target-version = "py311"

# Exclude Jupyter notebooks
extend-exclude = ["*.ipynb"]
Expand Down
21 changes: 16 additions & 5 deletions src/api/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
import json

from starlette.exceptions import HTTPException

from src.api.types import MCPConcept, AVAILABLE_FLAGS
from src.config import config
from src.config.config import get_session_request, get_settings
from src.logger import get_logger
from src.utils.async_to_sync import async_to_sync
from src.api.context import get_session_settings

# Set up logger for this module
logger = get_logger()
Expand Down Expand Up @@ -339,13 +341,17 @@ def get_org_id() -> str | None:
str or None: The organization ID, or None if using API key authentication
"""
settings = get_settings()
session_settings = get_session_settings()

# If using API key authentication, no org_id is needed
if not settings.is_remote and settings.api_key:
logger.debug("Using API key authentication, no organization ID needed")
return None

org_id = settings.org_id
if settings.is_remote and session_settings is not None:
org_id = session_settings.get("org_id", None)
else:
org_id = settings.org_id

if not org_id:
logger.debug(
Expand All @@ -368,18 +374,23 @@ def get_access_token() -> str:
logger.debug(f"Getting access token, is_remote: {settings.is_remote}")

access_token: str
if settings.is_remote:
if isinstance(settings, config.RemoteSettings) and settings.auth_provider:
request = get_session_request()
access_token = request.headers.get("Authorization", "").replace("Bearer ", "")
real_token = async_to_sync(settings.auth_provider.load_access_token)(
access_token
)
if real_token:
access_token = real_token.token
logger.debug(
f"Remote access token retrieved (length: {len(access_token) if access_token else 0})"
)
else:
elif isinstance(settings, config.LocalSettings):
# Check for API key first, then fall back to JWT token
if settings.api_key:
access_token = settings.api_key
logger.debug("Using API key for authentication")
else:
elif settings.jwt_token:
access_token = settings.jwt_token
logger.debug(
f"Local JWT token retrieved (length: {len(access_token) if access_token else 0})"
Expand Down
60 changes: 60 additions & 0 deletions src/api/context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Context variable for the API."""

from contextvars import ContextVar
from mcp.server.fastmcp import Context
from functools import wraps
import inspect
from typing import Callable, Dict, Any

# Context variable to hold the current context object of a given tool call
session_context = ContextVar("session_context")


def get_session_context() -> Context | None:
return session_context.get(None)


# Using session's context to store session-specific settings like org_id
def get_session_settings() -> Dict[str, Any] | None:
current_context = get_session_context()
if current_context is not None:
return current_context.request_context.lifespan_context

raise Exception(
"No session context available for this tool call, please try again."
)


def tool_wrapper(func: Callable) -> Callable:
"""
This function wraps tool functions to set the context variable.
"""

# Check if the underlying function accepts a 'ctx' parameter, or generic **kwargs
func_signature = inspect.signature(func)
accepts_ctx = "ctx" in func_signature.parameters or any(
p.kind == inspect.Parameter.VAR_KEYWORD
for p in func_signature.parameters.values()
)

@wraps(func)
async def wrapper(ctx, *args, **kwargs):
# Ensures that ctx is passed to the original function if it accepts it
kwargs_cpy = dict(kwargs)
if accepts_ctx:
kwargs_cpy["ctx"] = ctx

current_context = get_session_context()
if ctx is not None and current_context is None:
session_context.set(ctx.request_context)

if inspect.iscoroutinefunction(func):
return await func(*args, **kwargs_cpy)
else:
return func(*args, **kwargs_cpy)

# Ensures the mcp library knows that the wrapper expects a 'ctx' parameter
if not accepts_ctx:
wrapper.__annotations__["ctx"] = Context

return wrapper
18 changes: 14 additions & 4 deletions src/api/tools/organization/organization.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import src.api.tools.organization.utils as utils
from src.config import config
from src.api.common import query_graphql_organizations
from src.api.context import get_session_settings
from src.utils.elicitation import try_elicitation, ElicitationError
from src.logger import get_logger

Expand Down Expand Up @@ -72,6 +73,8 @@ async def choose_organization(ctx: Context) -> dict:

settings = config.get_settings()
user_id = config.get_user_id()
session_settings = get_session_settings()

# Track tool call event
settings.analytics_manager.track_event(
user_id, "tool_calling", {"name": "choose_organization"}
Expand Down Expand Up @@ -154,7 +157,10 @@ class OrganizationChoice(BaseModel):

# Set the selected organization in settings
if selected_org:
settings.org_id = selected_org["orgID"]
if settings.is_remote and session_settings:
session_settings["org_id"] = selected_org["orgID"]
else:
settings.org_id = selected_org["orgID"]

return {
"status": "success",
Expand Down Expand Up @@ -211,6 +217,7 @@ async def set_organization(ctx: Context, organization_id: str) -> dict:
3. Call set_organization with the chosen ID
"""
settings = config.get_settings()
session_settings = get_session_settings()
user_id = config.get_user_id()
# Track tool call event
settings.analytics_manager.track_event(
Expand Down Expand Up @@ -243,10 +250,13 @@ async def set_organization(ctx: Context, organization_id: str) -> dict:
}

# Set the selected organization in settings
if hasattr(settings, "org_id"):
settings.org_id = selected_org["orgID"]
if settings.is_remote and session_settings:
session_settings["org_id"] = selected_org["orgID"]
else:
setattr(settings, "org_id", selected_org["orgID"])
if hasattr(settings, "org_id"):
settings.org_id = selected_org["orgID"]
else:
setattr(settings, "org_id", selected_org["orgID"])

await ctx.info(
f"Organization set to: {selected_org['name']} (ID: {selected_org['orgID']})"
Expand Down
36 changes: 18 additions & 18 deletions src/api/tools/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,24 +29,24 @@

# Define the tools with their metadata
tools_definition = [
{"func": get_user_info},
{"func": organization_info},
{"func": choose_organization},
{"func": set_organization},
{"func": workspace_groups_info},
{"func": workspaces_info},
{"func": resume_workspace},
{"func": list_starter_workspaces},
{"func": create_starter_workspace},
{"func": terminate_starter_workspace},
{"func": list_regions},
{"func": list_sharedtier_regions},
{"func": run_sql},
{"func": create_notebook_file},
{"func": upload_notebook_file},
{"func": create_job_from_notebook},
{"func": get_job},
{"func": delete_job},
{"tool": get_user_info},
{"tool": organization_info},
{"tool": choose_organization},
{"tool": set_organization},
{"tool": workspace_groups_info},
{"tool": workspaces_info},
{"tool": resume_workspace},
{"tool": list_starter_workspaces},
{"tool": create_starter_workspace},
{"tool": terminate_starter_workspace},
{"tool": list_regions},
{"tool": list_sharedtier_regions},
{"tool": run_sql},
{"tool": create_notebook_file},
{"tool": upload_notebook_file},
{"tool": create_job_from_notebook},
{"tool": get_job},
{"tool": delete_job},
]

# Export the tools
Expand Down
6 changes: 6 additions & 0 deletions src/api/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
from enum import Flag, auto
from typing import Set

from src.api.context import tool_wrapper


# Define all possible flags here - ADD NEW FLAGS TO THIS LIST ONLY!
AVAILABLE_FLAGS = ["deprecated", "internal"]
Expand Down Expand Up @@ -83,6 +85,10 @@ def create_from_dict(cls, concept_def: dict):
flag_enum = getattr(MCPConceptFlags, flag_name.upper())
flags |= flag_enum

if "tool" in concept_attrs:
concept_attrs["func"] = tool_wrapper(concept_attrs["tool"])
del concept_attrs["tool"]

# Set title if not explicitly provided and we have a function
if "title" not in concept_attrs and "func" in concept_attrs:
concept_attrs["title"] = getattr(concept_attrs["func"], "__name__", "")
Expand Down
Loading
Loading