Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ jobs:
- name: Set up project
run: |

# Install sponge.
sudo apt-get install moreutils

# `setuptools 64` adds support for editable install hooks (PEP 660).
# https://github.com/pypa/setuptools/blob/main/CHANGES.rst#v6400
# `setuptools 71` significantly re-vendors setuptools packages, causing hiccups on Python 3.8.
Expand Down
2 changes: 2 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Changelog

## Unreleased
- MCP: Add subsystem providing a few server and client utilities through
the `ctk query mcp {list,inquire,launch}` subcommands.

## 2025/01/31 v0.0.31
- Fixed connectivity for `jobstats collect`
Expand Down
4 changes: 3 additions & 1 deletion cratedb_toolkit/query/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from ..util.cli import boot_click
from .convert.cli import convert_query
from .mcp.cli import cli as mcp_cli

logger = logging.getLogger(__name__)

Expand All @@ -16,9 +17,10 @@
@click.pass_context
def cli(ctx: click.Context, verbose: bool, debug: bool):
"""
Query expression utilities.
Query utilities.
"""
return boot_click(ctx, verbose, debug)


cli.add_command(convert_query, name="convert")
cli.add_command(mcp_cli, name="mcp")
Empty file.
92 changes: 92 additions & 0 deletions cratedb_toolkit/query/mcp/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import asyncio
import logging

import click
from click_aliases import ClickAliasedGroup

from cratedb_toolkit.query.mcp.inquiry import McpServerInquiry
from cratedb_toolkit.query.mcp.registry import McpServerRegistry
from cratedb_toolkit.query.mcp.util import format_output
from cratedb_toolkit.util.cli import boot_click

logger = logging.getLogger(__name__)


def get_format_option(default="markdown"):
return click.option(
"--format",
"format_",
type=click.Choice(["markdown", "json", "yaml"]),
required=True,
default=default,
help="Select output format",
)


format_option_markdown = get_format_option(default="markdown")
format_option_json = get_format_option(default="json")


@click.group(cls=ClickAliasedGroup) # type: ignore[arg-type]
@click.option("--server-name", type=str, required=False, help="Select MCP server name")
@click.option("--verbose", is_flag=True, required=False, help="Turn on logging")
@click.option("--debug", is_flag=True, required=False, help="Turn on logging with debug level")
@click.version_option()
@click.pass_context
def cli(ctx: click.Context, server_name: str, verbose: bool, debug: bool):
"""
MCP utilities.
"""
ctx.meta["registry"] = McpServerRegistry()
ctx.meta["server_name"] = server_name
return boot_click(ctx, verbose, debug)


@cli.command(name="list")
@format_option_json
@click.pass_context
def list_servers(
ctx: click.Context,
format_: str,
):
"""
Enumerate registered MCP servers.
"""
registry = ctx.meta["registry"]
server_name = ctx.meta["server_name"]
inquiry = McpServerInquiry(registry.select(server_name))
names = [server.name for server in inquiry.servers]
print(format_output(names, format_)) # noqa: T201


@cli.command()
@format_option_markdown
@click.pass_context
def inquire(
ctx: click.Context,
format_: str,
):
"""
Inquire MCP servers, report about their capabilities.
"""
registry = ctx.meta["registry"]
server_name = ctx.meta["server_name"]
inquiry = McpServerInquiry(registry.select(server_name))
result = asyncio.run(inquiry.format(format_))
print(result) # noqa: T201


@cli.command()
@click.pass_context
def launch(
ctx: click.Context,
):
"""
Launch MCP server.
"""
registry = ctx.meta["registry"]
server_name = ctx.meta["server_name"]
if not server_name:
raise click.BadParameter("MCP server name is required")

Check warning on line 90 in cratedb_toolkit/query/mcp/cli.py

View check run for this annotation

Codecov / codecov/patch

cratedb_toolkit/query/mcp/cli.py#L90

Added line #L90 was not covered by tests
servers = registry.select(server_name)
servers[0].launch()
135 changes: 135 additions & 0 deletions cratedb_toolkit/query/mcp/inquiry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# Inquire Model Context Protocol (MCP) servers.
#
# Usage:
#
# uvx 'cratedb-toolkit[mcp]' query mcp inquire --format=markdown | sponge mcp-cratedb-landscape.md
#
# ruff: noqa: T201
import dataclasses
import io
import logging
import typing as t
from contextlib import redirect_stdout

from cratedb_toolkit.query.mcp.model import McpServer

from .util import McpServerCapabilities, to_json, to_yaml

logger = logging.getLogger(__name__)


@dataclasses.dataclass
class McpServerInquiry:
"""
Inquire capabilities of MCP server, and format as JSON, Markdown, or YAML.
"""

servers: t.List[McpServer]

title = "Database MCP servers for PostgreSQL and CrateDB"
text = (
"This page provides an overview about a fragment of the MCP server landscape,\n"
"focusing on those that are talking to PostgreSQL and CrateDB databases.\n"
"\n"
"It enumerates the most popular adapters, and includes their detailed API capabilities.\n"
)

notes = (
":::{note}\n"
"This page was generated automatically, please do not edit manually. To rebuild, use this command:\n"
"```shell\n"
"uvx 'cratedb-toolkit[mcp]' query mcp inquire --format=markdown | sponge doc/query/mcp/landscape.md\n"
"```\n"
":::\n"
)

seealso = (
":::{seealso}\n"
"Ready-to-run example programs about all the adapters are available per\n"
"example collection about [exploring MCP with CrateDB].\n"
"[exploring MCP with CrateDB]: https://github.com/crate/cratedb-examples/tree/main/framework/mcp.\n"
":::\n"
)

def __post_init__(self):
if not self.servers:
msg = "No servers selected"
logger.error(msg)
raise UserWarning(msg)

Check warning on line 58 in cratedb_toolkit/query/mcp/inquiry.py

View check run for this annotation

Codecov / codecov/patch

cratedb_toolkit/query/mcp/inquiry.py#L56-L58

Added lines #L56 - L58 were not covered by tests

@staticmethod
async def get_capabilities(server: McpServer):
"""
Launch MCP server with stdio transport, and inquire API for capabilities.

Derived from:
https://github.com/modelcontextprotocol/python-sdk?tab=readme-ov-file#writing-mcp-clients
"""
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

if server.program is None:
raise ValueError("Program name for MCP server not defined")

Check warning on line 72 in cratedb_toolkit/query/mcp/inquiry.py

View check run for this annotation

Codecov / codecov/patch

cratedb_toolkit/query/mcp/inquiry.py#L72

Added line #L72 was not covered by tests

# Create server parameters for stdio connection.
server_params = StdioServerParameters(
command=server.program,
args=server.args,
env=server.env,
)

async with stdio_client(server_params) as (read, write):
async with ClientSession(read, write) as session:

Check warning on line 82 in cratedb_toolkit/query/mcp/inquiry.py

View check run for this annotation

Codecov / codecov/patch

cratedb_toolkit/query/mcp/inquiry.py#L82

Added line #L82 was not covered by tests
# Initialize the connection.
await session.initialize()

Check warning on line 84 in cratedb_toolkit/query/mcp/inquiry.py

View check run for this annotation

Codecov / codecov/patch

cratedb_toolkit/query/mcp/inquiry.py#L84

Added line #L84 was not covered by tests

# Inquire API.
response = McpServerCapabilities(session)
await response.inquire()
return response

Check warning on line 89 in cratedb_toolkit/query/mcp/inquiry.py

View check run for this annotation

Codecov / codecov/patch

cratedb_toolkit/query/mcp/inquiry.py#L87-L89

Added lines #L87 - L89 were not covered by tests

async def format(self, variant: str):
if variant == "json":
return await self.to_json()

Check warning on line 93 in cratedb_toolkit/query/mcp/inquiry.py

View check run for this annotation

Codecov / codecov/patch

cratedb_toolkit/query/mcp/inquiry.py#L93

Added line #L93 was not covered by tests
elif variant == "markdown":
return await self.to_markdown()
elif variant == "yaml":
return await self.to_yaml()

Check warning on line 97 in cratedb_toolkit/query/mcp/inquiry.py

View check run for this annotation

Codecov / codecov/patch

cratedb_toolkit/query/mcp/inquiry.py#L96-L97

Added lines #L96 - L97 were not covered by tests
else:
raise NotImplementedError(f"Output variant not implemented: {variant}")

Check warning on line 99 in cratedb_toolkit/query/mcp/inquiry.py

View check run for this annotation

Codecov / codecov/patch

cratedb_toolkit/query/mcp/inquiry.py#L99

Added line #L99 was not covered by tests

async def to_dict(self):
payload: t.Dict[str, t.Any] = {

Check warning on line 102 in cratedb_toolkit/query/mcp/inquiry.py

View check run for this annotation

Codecov / codecov/patch

cratedb_toolkit/query/mcp/inquiry.py#L102

Added line #L102 was not covered by tests
"meta": {"title": self.title, "text": self.text, "notes": self.notes, "seealso": self.seealso},
"data": {},
}
for server in self.servers:
capabilities = await self.get_capabilities(server)
payload["data"][server.name] = {

Check warning on line 108 in cratedb_toolkit/query/mcp/inquiry.py

View check run for this annotation

Codecov / codecov/patch

cratedb_toolkit/query/mcp/inquiry.py#L106-L108

Added lines #L106 - L108 were not covered by tests
"meta": server.to_dict(),
"capabilities": capabilities.to_dict(),
}
return payload

Check warning on line 112 in cratedb_toolkit/query/mcp/inquiry.py

View check run for this annotation

Codecov / codecov/patch

cratedb_toolkit/query/mcp/inquiry.py#L112

Added line #L112 was not covered by tests

async def to_markdown(self):
buffer = io.StringIO()
with redirect_stdout(buffer):
print(f"# {self.title}")
print()
print(self.text)
for server in self.servers:
print(server.to_markdown())
try:
capabilities = await self.get_capabilities(server)
print(capabilities.to_markdown())

Check warning on line 124 in cratedb_toolkit/query/mcp/inquiry.py

View check run for this annotation

Codecov / codecov/patch

cratedb_toolkit/query/mcp/inquiry.py#L124

Added line #L124 was not covered by tests
except Exception as ex:
logger.error(f"MCP server capability inquiry failed: {ex}")
print(self.notes)
print(self.seealso)
return buffer.getvalue()

async def to_json(self):
return to_json(await self.to_dict())

Check warning on line 132 in cratedb_toolkit/query/mcp/inquiry.py

View check run for this annotation

Codecov / codecov/patch

cratedb_toolkit/query/mcp/inquiry.py#L132

Added line #L132 was not covered by tests

async def to_yaml(self):
return to_yaml(await self.to_dict())

Check warning on line 135 in cratedb_toolkit/query/mcp/inquiry.py

View check run for this annotation

Codecov / codecov/patch

cratedb_toolkit/query/mcp/inquiry.py#L135

Added line #L135 was not covered by tests
Loading