Skip to main content

Spindl

Quality Gate Status Reliability Rating Security Rating Maintainability Rating Vulnerabilities Bugs Lines of Code Duplicated Lines (%)

A Python framework for building Model Context Protocol (MCP) servers with hierarchical tool name prefixing, on-demand skills guides, and built-in response spooling for large data sets.

The Problem

When an MCP client connects to multiple servers, tool name collisions cause confusion. If two servers both expose list_devices, the LLM doesn't know which one to call. Worse, tool descriptions and guides that reference other tools by name become ambiguous.

The Solution

Spindl solves this with three capabilities:

  • Hierarchical prefix namespacing -- Every tool name is automatically prefixed with a server identity (e.g. secops_list_devices), with an optional runtime instance prefix for multi-deployment scenarios (e.g. prod_secops_list_devices).
  • Skills guide tools -- Instead of bloating tool descriptions with lengthy usage guides, spindl auto-registers list_tools and describe_tool tools that the LLM can call on demand.
  • Response spooling -- Large array responses are automatically stored in an ephemeral SQLite database with query, aggregate, and distinct tools for efficient data exploration.

Installation

pip install cognisn-spindl

For HTTP/SSE transport support:

pip install cognisn-spindl[http]

For development:

pip install cognisn-spindl[dev]

Quick Start

import asyncio
from pydantic import BaseModel, Field
from spindl import MCPServer, BaseTool

class ListDevices(BaseTool):
    name = "list_devices"
    description = "List all monitored devices"
    category = "inventory"
    spooler_auto_detect = True  # Auto-spool large responses

    class InputModel(BaseModel):
        limit: int = Field(default=50, ge=1, le=500, description="Max devices to return")

    def guide(self) -> str:
        return (
            "# @list_devices\n\n"
            "Returns all monitored devices. Large result sets are "
            "automatically spooled -- use @spooler_query to filter "
            "and paginate through the data.\n\n"
            "## Examples\n\n"
            '```json\n{"limit": 100}\n```\n'
        )

    async def execute(self, **params) -> dict:
        validated = self.InputModel(**params)
        # Your API logic here
        devices = [{"id": i, "name": f"device-{i}"} for i in range(validated.limit)]
        return {"success": True, "data": devices}

# Create server with prefix and optional spooler
from spindl import SpoolerConfig

server = MCPServer(
    prefix="secops",
    spooler=SpoolerConfig(),  # Enables response spooling
)
server.register(ListDevices())

# Run on stdio (for Claude Desktop, Cursor, etc.)
asyncio.run(server.run_stdio())

This server exposes the following tools to the MCP client:

Wire Name Source
secops_list_devices Your custom tool
secops_list_tools Auto-registered skills guide
secops_describe_tool Auto-registered skills guide
secops_spooler_list Auto-registered (spooler enabled)
secops_spooler_query Auto-registered (spooler enabled)
secops_spooler_aggregate Auto-registered (spooler enabled)
secops_spooler_distinct Auto-registered (spooler enabled)

Architecture

                         MCPServer
                        /    |     \
              PrefixResolver |  ToolRegistry
                             |       |
                    MCP SDK Server   Tools (bare names)
                    /    |    \        |
                stdio  http   sse   Prefixed at boundary

Core Components

Component Module Role
MCPServer spindl.server Top-level orchestrator. Owns everything.
PrefixResolver spindl.prefix Hierarchical prefix engine with @placeholder resolution
ToolRegistry spindl.registry Stores tools by bare name, prefixes at the MCP boundary
BaseTool spindl.tool Clean base class for tool authors
ResponseSpooler spindl.spooler SQLite-backed large response handler

Design Principles

  • Tools never know their prefix. They are stored by bare name internally. Prefixing happens at the MCP protocol boundary.
  • Guides use @placeholder syntax. Write @spooler_query in your guide text; it resolves to secops_spooler_query (or prod_secops_spooler_query) at runtime.
  • The spooler core is stdlib-only. No external dependencies beyond Python's built-in sqlite3, json, and hashlib.
  • Transports are swappable. Call run_stdio(), run_http(), or run_sse() -- your tools don't change.

Prefix System

Level 1: Server Prefix (Code)

Set by the developer. Mandatory. Identifies the server.

server = MCPServer(prefix="secops")
# All tools: secops_list_devices, secops_list_tools, ...

Level 2: Instance Prefix (Runtime)

Optional. Identifies a deployment instance. Useful when the same server is deployed multiple times with different purposes.

Via environment variable:

export SPINDL_INSTANCE_PREFIX=prod
# All tools: prod_secops_list_devices, prod_secops_list_tools, ...

Via HTTP header (for HTTP/SSE transports):

X-Spindl-Prefix: prod

Header takes precedence over the environment variable. Each HTTP request can have a different prefix (isolated via contextvars).

Placeholder Resolution

Tool guides reference other tools using @bare_name syntax:

def guide(self) -> str:
    return "Use @list_devices to get devices. Query results with @spooler_query."

At runtime, these resolve to fully prefixed wire names:

"Use secops_list_devices to get devices. Query results with secops_spooler_query."

Only registered tool names are replaced. Unknown @references pass through untouched.

Building Tools

Minimal Tool

from spindl import BaseTool

class Ping(BaseTool):
    name = "ping"
    description = "Health check"
    category = "meta"

    async def execute(self, **params) -> dict:
        return {"success": True, "data": "pong"}

Tool with Parameters

from pydantic import BaseModel, Field
from spindl import BaseTool

class SearchVulns(BaseTool):
    name = "search_vulns"
    description = "Search vulnerability database"
    category = "security"
    spooler_array_paths = ["results"]  # Spool the 'results' array

    class InputModel(BaseModel):
        query: str = Field(description="Search query")
        severity: str | None = Field(default=None, description="Filter by severity")
        limit: int = Field(default=50, ge=1, le=500, description="Max results")

    def guide(self) -> str:
        return (
            "# @search_vulns\n\n"
            "Search for vulnerabilities by keyword. Results are "
            "automatically spooled when large.\n\n"
            "## Parameters\n\n"
            "- **query** (required): Search keywords\n"
            "- **severity** (optional): Filter by critical/high/medium/low\n"
            "- **limit** (optional): Max results (default 50)\n\n"
            "## Examples\n\n"
            '```json\n{"query": "CVE-2024", "severity": "critical"}\n```\n\n'
            "## Follow-up\n\n"
            "Use @spooler_query to filter results, @spooler_aggregate "
            "for severity breakdowns, or @spooler_distinct to see "
            "affected vendors.\n"
        )

    async def execute(self, **params) -> dict:
        validated = self.InputModel(**params)
        # Your search logic here
        results = [...]
        return {"success": True, "data": {"results": results}}

Tool Attributes Reference

Attribute Type Default Description
name str "" Bare tool name (required)
description str "" Short one-line description (required)
category str "" Grouping key for skills guide (required)
spooler_array_paths list[str] | None None Dot-notation paths to arrays to spool
spooler_auto_detect bool False Auto-detect large arrays in response
InputModel type[BaseModel] | None None Pydantic model for input validation

Spooler Opt-In

Tools opt into response spooling via two attributes:

  • spooler_array_paths -- Explicit dot-notation paths: ["results", "data.items"]
  • spooler_auto_detect -- Let the spooler find arrays automatically

When the response array exceeds the configured thresholds (max_inline_items or max_inline_tokens), it's stored in SQLite and replaced with a summary containing the spool_id.

Response Spooler

Configuration

from spindl import MCPServer, SpoolerConfig

server = MCPServer(
    prefix="secops",
    spooler=SpoolerConfig(
        db_path="/tmp/spooler.db",      # SQLite file location
        max_inline_tokens=2000,          # Token threshold for spooling
        max_inline_items=10,             # Item count threshold
        default_page_size=20,            # Default records per query page
        max_page_size=50,                # Hard ceiling on page size
        summary_sample_size=3,           # Sample records in summary
        db_cleanup_on_exit=True,         # Delete DB on shutdown
    ),
)

All settings can also be configured via environment variables:

Env Var Default
SPOOLER_DB_PATH /tmp/mcp_spooler.db
SPOOLER_MAX_INLINE_TOKENS 2000
SPOOLER_MAX_INLINE_ITEMS 10
SPOOLER_DEFAULT_PAGE_SIZE 20
SPOOLER_MAX_PAGE_SIZE 50
SPOOLER_SUMMARY_SAMPLE_SIZE 3
SPOOLER_CLEANUP_ON_EXIT true

How It Works

  1. A tool returns a response with a large array
  2. The spooler detects the array exceeds thresholds
  3. The array is stored in SQLite with a unique spool_id
  4. The LLM receives a summary with record count, column names, statistics, sample records, and the spool_id
  5. The LLM uses the spooler query tools to explore the data

Spooler Query Tools

Tool Purpose
{prefix}_spooler_list List all available spooled data sets
{prefix}_spooler_query Filter, sort, paginate, search records
{prefix}_spooler_aggregate Group-by with count/sum/avg/min/max
{prefix}_spooler_distinct Unique values and frequency counts

Skills Guide

Every spindl server auto-registers two tools:

{prefix}_list_tools

Returns all registered tools grouped by category. No parameters needed.

{
  "success": true,
  "data": {
    "total_tools": 7,
    "categories": {
      "inventory": [
        {"name": "secops_list_devices", "description": "List all monitored devices"}
      ],
      "skills": [
        {"name": "secops_list_tools", "description": "List all available tools..."},
        {"name": "secops_describe_tool", "description": "Get detailed usage guide..."}
      ],
      "spooler": [
        {"name": "secops_spooler_list", "description": "List all spooled data sets..."},
        ...
      ]
    }
  }
}

{prefix}_describe_tool

Returns the detailed guide for a specific tool, with all @placeholders resolved to prefixed wire names.

{"tool_name": "secops_list_devices"}

Transports

stdio

For local MCP clients (Claude Desktop, Cursor, VS Code, etc.):

asyncio.run(server.run_stdio())

HTTP Streamable

For networked deployments. Requires pip install cognisn-spindl[http].

asyncio.run(server.run_http(host="0.0.0.0", port=8000))

The server reads the X-Spindl-Prefix header from each request for per-request instance prefixing.

SSE (Server-Sent Events)

For streaming connections. Requires pip install cognisn-spindl[http].

asyncio.run(server.run_sse(host="0.0.0.0", port=8000))

Response Types

Spindl includes self-contained response types for consistent tool output:

from spindl import ResponseEnvelope, ResponseMetadata, StructuredError, ErrorDetail

# Success response
return ResponseEnvelope(
    success=True,
    data={"items": results},
    metadata=ResponseMetadata(
        total_results=len(results),
        returned_results=len(results),
    ),
).to_dict()

# Error response
return StructuredError(
    error=ErrorDetail(
        error_code="AUTH_ERROR",
        error_message="Invalid API key",
        retry_eligible=False,
        suggestion="Check your API key configuration.",
    ),
).to_dict()

API Reference

MCPServer

MCPServer(
    prefix: str,                         # Mandatory server prefix
    spooler: SpoolerConfig | None,       # Enable response spooling
    server_name: str | None = None,      # MCP server name (defaults to prefix)
)

Methods:

Method Description
register(tool) Register a single tool
register_all(tools) Register a list of tools
run_stdio() Run on stdio transport
run_http(host, port) Run on HTTP streamable transport
run_sse(host, port) Run on SSE transport

BaseTool

class MyTool(BaseTool):
    name: str               # Bare name (e.g. "get_devices")
    description: str        # Short description
    category: str           # Grouping key
    spooler_array_paths: list[str] | None = None
    spooler_auto_detect: bool = False
    InputModel: type[BaseModel] | None = None

    def guide(self) -> str: ...           # Usage guide with @placeholders
    async def execute(self, **params) -> dict: ...  # Tool logic

PrefixResolver

resolver = PrefixResolver("secops")
resolver.prefixed_name("get_devices")       # "secops_get_devices"
resolver.strip_prefix("secops_get_devices") # "get_devices"
resolver.resolve_placeholders("@get_devices") # "secops_get_devices"
resolver.set_instance_prefix("prod")        # Per-request override

Requirements

  • Python >= 3.12
  • mcp >= 1.25.0
  • pydantic >= 2.0.0
  • uvicorn >= 0.30.0 (optional, for HTTP/SSE transports)

License

MIT

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

cognisn_spindl-0.2.0.tar.gz (44.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

cognisn_spindl-0.2.0-py3-none-any.whl (48.1 kB view details)

Uploaded Python 3

File details

Details for the file cognisn_spindl-0.2.0.tar.gz.

File metadata

  • Download URL: cognisn_spindl-0.2.0.tar.gz
  • Upload date:
  • Size: 44.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.13

File hashes

Hashes for cognisn_spindl-0.2.0.tar.gz
Algorithm Hash digest
SHA256 5cb0933236384b11973d1dcbc245d13959180b139b8607fb4d922d3a5e2d4e28
MD5 cf788fff377f4bc480326bdb276efab0
BLAKE2b-256 b0c25333373514583c8de09c9f3dc48a8f915c128c4549f26ea71e6d063ecd9e

See more details on using hashes here.

Provenance

The following attestation bundles were made for cognisn_spindl-0.2.0.tar.gz:

Publisher: publish.yml on Cognisn/spindl

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file cognisn_spindl-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: cognisn_spindl-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 48.1 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.13

File hashes

Hashes for cognisn_spindl-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 f95e365546add5ded49f373b44472bf0e25aeaa48906ebfeabc1e40a9c9175c0
MD5 ae2e1b4bcd5725cd1d3c62f5a1a2b1f2
BLAKE2b-256 2ded77232136c5a2d168b8a70365f3b104e17b431b35ab7a703bb36cd4e7b37b

See more details on using hashes here.

Provenance

The following attestation bundles were made for cognisn_spindl-0.2.0-py3-none-any.whl:

Publisher: publish.yml on Cognisn/spindl

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

0.3.0

2 files

This release

0.2.0 This release

2 files

0.1.2

2 files

0.1.1

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page