Skip to content

Commit ea02ea6

Browse files
committed
MCP: Add subsystem providing a few server and client utilities
Through the `ctk query mcp {list,inquire,launch}` subcommands.
1 parent d54ce2b commit ea02ea6

File tree

14 files changed

+830
-2
lines changed

14 files changed

+830
-2
lines changed

.github/workflows/main.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ jobs:
6868
- name: Set up project
6969
run: |
7070
71+
# Install sponge.
72+
sudo apt-get install moreutils
73+
7174
# `setuptools 64` adds support for editable install hooks (PEP 660).
7275
# https://github.com/pypa/setuptools/blob/main/CHANGES.rst#v6400
7376
# `setuptools 71` significantly re-vendors setuptools packages, causing hiccups on Python 3.8.

CHANGES.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# Changelog
22

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

57
## 2025/01/31 v0.0.31
68
- Fixed connectivity for `jobstats collect`

cratedb_toolkit/query/cli.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from ..util.cli import boot_click
77
from .convert.cli import convert_query
8+
from .mcp.cli import cli as mcp_cli
89

910
logger = logging.getLogger(__name__)
1011

@@ -16,9 +17,10 @@
1617
@click.pass_context
1718
def cli(ctx: click.Context, verbose: bool, debug: bool):
1819
"""
19-
Query expression utilities.
20+
Query utilities.
2021
"""
2122
return boot_click(ctx, verbose, debug)
2223

2324

2425
cli.add_command(convert_query, name="convert")
26+
cli.add_command(mcp_cli, name="mcp")

cratedb_toolkit/query/mcp/__init__.py

Whitespace-only changes.

cratedb_toolkit/query/mcp/cli.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import asyncio
2+
import logging
3+
4+
import click
5+
from click_aliases import ClickAliasedGroup
6+
7+
from cratedb_toolkit.query.mcp.inquiry import McpServerInquiry
8+
from cratedb_toolkit.query.mcp.registry import McpServerRegistry
9+
from cratedb_toolkit.query.mcp.util import format_output
10+
from cratedb_toolkit.util.cli import boot_click
11+
12+
logger = logging.getLogger(__name__)
13+
14+
15+
def get_format_option(default="markdown"):
16+
return click.option(
17+
"--format",
18+
"format_",
19+
type=click.Choice(["markdown", "json", "yaml"]),
20+
required=True,
21+
default=default,
22+
help="Select output format",
23+
)
24+
25+
26+
format_option_markdown = get_format_option(default="markdown")
27+
format_option_json = get_format_option(default="json")
28+
29+
30+
@click.group(cls=ClickAliasedGroup) # type: ignore[arg-type]
31+
@click.option("--server-name", type=str, required=False, help="Select MCP server name")
32+
@click.option("--verbose", is_flag=True, required=False, help="Turn on logging")
33+
@click.option("--debug", is_flag=True, required=False, help="Turn on logging with debug level")
34+
@click.version_option()
35+
@click.pass_context
36+
def cli(ctx: click.Context, server_name: str, verbose: bool, debug: bool):
37+
"""
38+
MCP utilities.
39+
"""
40+
ctx.meta["registry"] = McpServerRegistry()
41+
ctx.meta["server_name"] = server_name
42+
return boot_click(ctx, verbose, debug)
43+
44+
45+
@cli.command(name="list")
46+
@format_option_json
47+
@click.pass_context
48+
def list_servers(
49+
ctx: click.Context,
50+
format_: str,
51+
):
52+
"""
53+
Enumerate registered MCP servers.
54+
"""
55+
registry = ctx.meta["registry"]
56+
server_name = ctx.meta["server_name"]
57+
inquiry = McpServerInquiry(registry.select(server_name))
58+
names = [server.name for server in inquiry.servers]
59+
print(format_output(names, format_)) # noqa: T201
60+
61+
62+
@cli.command()
63+
@format_option_markdown
64+
@click.pass_context
65+
def inquire(
66+
ctx: click.Context,
67+
format_: str,
68+
):
69+
"""
70+
Inquire MCP servers, report about their capabilities.
71+
"""
72+
registry = ctx.meta["registry"]
73+
server_name = ctx.meta["server_name"]
74+
inquiry = McpServerInquiry(registry.select(server_name))
75+
result = asyncio.run(inquiry.format(format_))
76+
print(result) # noqa: T201
77+
78+
79+
@cli.command()
80+
@click.pass_context
81+
def launch(
82+
ctx: click.Context,
83+
):
84+
"""
85+
Launch MCP server.
86+
"""
87+
registry = ctx.meta["registry"]
88+
server_name = ctx.meta["server_name"]
89+
if not server_name:
90+
raise click.BadParameter("MCP server name is required")
91+
servers = registry.select(server_name)
92+
servers[0].launch()

cratedb_toolkit/query/mcp/inquiry.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
# Inquire Model Context Protocol (MCP) servers.
2+
#
3+
# Usage:
4+
#
5+
# uvx 'cratedb-toolkit[mcp]' query mcp inquire --format=markdown | sponge mcp-cratedb-landscape.md
6+
#
7+
# ruff: noqa: T201
8+
import dataclasses
9+
import io
10+
import logging
11+
import typing as t
12+
from contextlib import redirect_stdout
13+
14+
from cratedb_toolkit.query.mcp.model import McpServer
15+
16+
from .util import McpServerCapabilities, to_json, to_yaml
17+
18+
logger = logging.getLogger(__name__)
19+
20+
21+
@dataclasses.dataclass
22+
class McpServerInquiry:
23+
"""
24+
Inquire capabilities of MCP server, and format as JSON, Markdown, or YAML.
25+
"""
26+
27+
servers: t.List[McpServer]
28+
29+
title = "Database MCP servers for PostgreSQL and CrateDB"
30+
text = (
31+
"This page provides an overview about a fragment of the MCP server landscape,\n"
32+
"focusing on those that are talking to PostgreSQL and CrateDB databases.\n"
33+
"\n"
34+
"It enumerates the most popular adapters, and includes their detailed API capabilities.\n"
35+
)
36+
37+
notes = (
38+
":::{note}\n"
39+
"This page was generated automatically, please do not edit manually. To rebuild, use this command:\n"
40+
"```shell\n"
41+
"uvx 'cratedb-toolkit[mcp]' query mcp inquire --format=markdown | sponge doc/query/mcp/landscape.md\n"
42+
"```\n"
43+
":::\n"
44+
)
45+
46+
seealso = (
47+
":::{seealso}\n"
48+
"Ready-to-run example programs about all the adapters are available at\n"
49+
"https://github.com/crate/cratedb-examples/tree/main/framework/mcp.\n"
50+
":::\n"
51+
)
52+
53+
def __post_init__(self):
54+
if not self.servers:
55+
msg = "No servers selected"
56+
logger.error(msg)
57+
raise UserWarning(msg)
58+
59+
@staticmethod
60+
async def get_capabilities(server: McpServer):
61+
"""
62+
Launch MCP server with stdio transport, and inquire API for capabilities.
63+
64+
Derived from:
65+
https://github.com/modelcontextprotocol/python-sdk?tab=readme-ov-file#writing-mcp-clients
66+
"""
67+
from mcp import ClientSession, StdioServerParameters
68+
from mcp.client.stdio import stdio_client
69+
70+
if server.program is None:
71+
raise ValueError("Program name for MCP server not defined")
72+
73+
# Create server parameters for stdio connection.
74+
server_params = StdioServerParameters(
75+
command=server.program,
76+
args=server.args,
77+
env=server.env,
78+
)
79+
80+
async with stdio_client(server_params) as (read, write):
81+
async with ClientSession(read, write) as session:
82+
# Initialize the connection.
83+
await session.initialize()
84+
85+
# Inquire API.
86+
response = McpServerCapabilities(session)
87+
await response.inquire()
88+
return response
89+
90+
async def format(self, variant: str):
91+
if variant == "json":
92+
return await self.to_json()
93+
elif variant == "markdown":
94+
return await self.to_markdown()
95+
elif variant == "yaml":
96+
return await self.to_yaml()
97+
else:
98+
raise NotImplementedError(f"Output variant not implemented: {variant}")
99+
100+
async def to_dict(self):
101+
payload: t.Dict[str, t.Any] = {
102+
"meta": {"title": self.title, "text": self.text, "notes": self.notes, "seealso": self.seealso},
103+
"data": {},
104+
}
105+
for server in self.servers:
106+
capabilities = await self.get_capabilities(server)
107+
payload["data"][server.name] = {
108+
"meta": server.to_dict(),
109+
"capabilities": capabilities.to_dict(),
110+
}
111+
return payload
112+
113+
async def to_markdown(self):
114+
buffer = io.StringIO()
115+
with redirect_stdout(buffer):
116+
print(f"# {self.title}")
117+
print()
118+
print(self.text)
119+
for server in self.servers:
120+
print(server.to_markdown())
121+
try:
122+
capabilities = await self.get_capabilities(server)
123+
print(capabilities.to_markdown())
124+
except Exception as ex:
125+
logger.error(f"MCP server capability inquiry failed: {ex}")
126+
print(self.notes)
127+
print(self.seealso)
128+
return buffer.getvalue()
129+
130+
async def to_json(self):
131+
return to_json(await self.to_dict())
132+
133+
async def to_yaml(self):
134+
return to_yaml(await self.to_dict())

0 commit comments

Comments
 (0)