Skip to content

Commit c51619f

Browse files
committed
MCP: Add example about PG-MCP
1 parent 63ac998 commit c51619f

9 files changed

+205
-8
lines changed

.github/workflows/framework-mcp.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,11 @@ jobs:
8383
8484
- name: Install utilities
8585
run: |
86+
87+
# Install sponge.
88+
sudo apt-get install moreutils
89+
90+
# Install pueblo.
8691
uv pip install -r requirements.txt
8792
8893
- name: Validate framework/mcp

framework/mcp/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
uv.lock
2+
pg-mcp

framework/mcp/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ program.
4040
server package, providing a range of tools. It is written in Python, and uses
4141
[SQLAlchemy] and the [CrateDB SQLAlchemy dialect].
4242

43+
- `example_pg_mcp.py`:
44+
The [PG-MCP] server is specialised to talk to PostgreSQL servers. With a few adjustments,
45+
the adapter can also talk to CrateDB. The project offers rich MCP server capabilities,
46+
and includes advanced client programs for Claude and Gemini that work out of the box.
47+
It is written in Python, optionally to be invoked with `uv` or `uvx`.
48+
4349
## Resources
4450

4551
- Read a [brief introduction to MCP] by ByteByteGo.
@@ -142,6 +148,7 @@ unlocking more details and features.
142148
[@modelcontextprotocol/server-postgres]: https://www.npmjs.com/package/@modelcontextprotocol/server-postgres
143149
[npx]: https://docs.npmjs.com/cli/v11/commands/npx
144150
[oterm configuration]: https://ggozad.github.io/oterm/tools/mcp/
151+
[PG-MCP]: https://github.com/stuzero/pg-mcp-server
145152
[quarkus-mcp-servers]: https://github.com/quarkiverse/quarkus-mcp-servers
146153
[SQLAlchemy]: https://sqlalchemy.org/
147154
[uv]: https://docs.astral.sh/uv/

framework/mcp/backlog.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@
99
https://github.com/crate/crate/issues/17393
1010
- DBHub: Reading resource `tables` does not work,
1111
because `WHERE table_schema = 'public'`
12+
- PG-MCP: Improve installation after packaging has been improved.
13+
https://github.com/stuzero/pg-mcp-server/issues/10
14+
- PG-MCP: Resource `pgmcp://{conn_id}/` makes `asyncpg` invoke
15+
`WITH RECURSIVE typeinfo_tree`, which fails on CrateDB.
16+
- https://github.com/crate/crate/issues/11757
17+
- https://github.com/crate/crate/issues/12544
18+
- PG-MCP: Fix `/rowcount endpoint`
1219

1320
## Iteration +2
1421
- General: Evaluate all connectors per `stdio` and `sse`, where possible

framework/mcp/example_pg_mcp.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# PG-MCP Model Context Protocol Server for CrateDB
2+
# https://github.com/stuzero/pg-mcp-server
3+
# https://github.com/crate-workbench/pg-mcp-server
4+
#
5+
# Derived from:
6+
# https://github.com/modelcontextprotocol/python-sdk?tab=readme-ov-file#writing-mcp-clients
7+
from cratedb_toolkit.util import DatabaseAdapter
8+
from mcp import ClientSession, StdioServerParameters
9+
from mcp.client.stdio import stdio_client
10+
import where
11+
12+
from mcp_utils import McpDatabaseConversation
13+
14+
15+
async def run():
16+
# Create server parameters for stdio connection.
17+
server_params = StdioServerParameters(
18+
command=where.first("python"),
19+
args=["example_pg_mcp_server.py"],
20+
env={},
21+
)
22+
23+
async with stdio_client(server_params) as (read, write):
24+
async with ClientSession(
25+
read, write
26+
) as session:
27+
# Initialize the connection.
28+
await session.initialize()
29+
30+
client = McpDatabaseConversation(session)
31+
await client.inquire()
32+
33+
print("## MCP server conversations")
34+
print()
35+
36+
# Provision database content.
37+
db = DatabaseAdapter("crate://crate@localhost:4200/")
38+
db.run_sql("CREATE TABLE IF NOT EXISTS mcp_pg_mcp (id INT, data TEXT)")
39+
db.run_sql("INSERT INTO mcp_pg_mcp (id, data) VALUES (42, 'Hotzenplotz')")
40+
db.refresh_table("mcp_pg_mcp")
41+
42+
# Call a few tools.
43+
connection_string = "postgresql://crate@localhost/doc"
44+
45+
# Connect to the database, receiving the connection UUID.
46+
response = await client.call_tool("connect", arguments={"connection_string": connection_string})
47+
conn_id = client.decode_json_text(response)["conn_id"]
48+
49+
# Query and explain, using the connection id.
50+
await client.call_tool("pg_query", arguments={
51+
"query": "SELECT * FROM sys.summits ORDER BY height DESC LIMIT 3",
52+
"conn_id": conn_id,
53+
})
54+
await client.call_tool("pg_explain", arguments={
55+
"query": "SELECT * FROM mcp_pg_mcp",
56+
"conn_id": conn_id,
57+
})
58+
59+
# Read a few resources.
60+
schema = "sys"
61+
table = "summits"
62+
await client.read_resource(f"pgmcp://{conn_id}/")
63+
await client.read_resource(f"pgmcp://{conn_id}/schemas")
64+
await client.read_resource(f"pgmcp://{conn_id}/schemas/{schema}/tables")
65+
await client.read_resource(f"pgmcp://{conn_id}/schemas/{schema}/tables/{table}/columns")
66+
#await client.read_resource(f"pgmcp://{conn_id}/schemas/{schema}/tables/{table}/indexes")
67+
#await client.read_resource(f"pgmcp://{conn_id}/schemas/{schema}/tables/{table}/constraints")
68+
#await client.read_resource(f"pgmcp://{conn_id}/schemas/{schema}/tables/{table}/indexes/{index}")
69+
#await client.read_resource(f"pgmcp://{conn_id}/schemas/{schema}/tables/{table}/constraints/{constraint}")
70+
#await client.read_resource(f"pgmcp://{conn_id}/schemas/{schema}/extensions")
71+
#await client.read_resource(f"pgmcp://{conn_id}/schemas/{schema}/extensions/{extension}")
72+
await client.read_resource(f"pgmcp://{conn_id}/schemas/{schema}/tables/{table}/sample")
73+
await client.read_resource(f"pgmcp://{conn_id}/schemas/{schema}/tables/{table}/rowcount")
74+
75+
# Invoke a prompt.
76+
await client.get_prompt("nl_to_sql_prompt", arguments={
77+
"query": "Give me 5 Austria mountains",
78+
})
79+
80+
# Disconnect again.
81+
await client.call_tool("disconnect", arguments={"conn_id": conn_id,})
82+
83+
84+
if __name__ == "__main__":
85+
import asyncio
86+
87+
asyncio.run(run())
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
#!/bin/sh
2+
# Acquire MCP server part of `pg-mcp`.
3+
# https://github.com/stuzero/pg-mcp-server
4+
# https://github.com/crate-workbench/pg-mcp-server
5+
6+
# FIXME: Improve installation after packaging has been improved.
7+
# https://github.com/stuzero/pg-mcp-server/issues/10
8+
9+
set -e
10+
TARGET="/tmp/pg-mcp-server"
11+
rm -rf ${TARGET}
12+
git clone --depth 1 --no-checkout --filter=blob:none \
13+
https://github.com/crate-workbench/pg-mcp-server.git \
14+
${TARGET}
15+
cd ${TARGET}
16+
git checkout 82733d1a886bf1a14592d1fbb305205901f2bb35 -- pyproject.toml uv.lock server test.py
17+
cat pyproject.toml | grep -v requires-python | sponge pyproject.toml
18+
uv pip install .
19+
20+
# /Users/amo/dev/crate-workbench/sources/pg-mcp-server
21+
# https://github.com/crate-workbench/pg-mcp.git
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
if __name__ == "__main__":
2+
# FIXME: Improve invocation after packaging has been improved.
3+
# https://github.com/stuzero/pg-mcp-server/issues/10
4+
from server.app import logger, mcp
5+
6+
# TODO: Bring flexible invocation (sse vs. stdio) to mainline.
7+
logger.info("Starting MCP server with STDIO transport")
8+
mcp.run(transport="stdio")

framework/mcp/mcp_utils.py

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import io
22
import json
3+
import logging
4+
35
import mcp.types as types
46
from typing import Any
57

@@ -9,13 +11,20 @@
911
from pydantic import AnyUrl
1012

1113

14+
logger = logging.getLogger(__name__)
15+
16+
1217
class McpDatabaseConversation:
1318
"""
1419
Wrap database conversations through MCP servers.
1520
"""
1621
def __init__(self, session: ClientSession):
1722
self.session = session
1823

24+
@staticmethod
25+
def decode_json_text(thing):
26+
return json.loads(thing.content[0].text)
27+
1928
def decode_items(self, items):
2029
return list(map(self.decode_item, json.loads(pydantic_core.to_json(items))))
2130

@@ -36,26 +45,38 @@ def list_items(self, items):
3645
buffer.write("```\n")
3746
return buffer.getvalue()
3847

48+
async def entity_info(self, fun, attribute):
49+
try:
50+
return self.list_items(getattr(await fun(), attribute))
51+
except McpError as e:
52+
logger.error(f"Not implemented on this server: {e}")
53+
54+
@staticmethod
55+
def dump_info(results):
56+
if results:
57+
print(results)
58+
print()
59+
3960
async def inquire(self):
4061
print("# MCP server inquiry")
4162
print()
4263

4364
# List available prompts
4465
print("## Prompts")
45-
try:
46-
print(self.list_items((await self.session.list_prompts()).prompts))
47-
except McpError as e:
48-
print(f"Not implemented on this server: {e}")
49-
print()
66+
self.dump_info(await self.entity_info(self.session.list_prompts, "prompts"))
5067

51-
# List available resources
68+
# List available resources and resource templates
5269
print("## Resources")
53-
print(self.list_items((await self.session.list_resources()).resources))
70+
self.dump_info(await self.entity_info(self.session.list_resources, "resources"))
71+
print()
72+
73+
print("## Resource templates")
74+
self.dump_info(await self.entity_info(self.session.list_resource_templates, "resourceTemplates"))
5475
print()
5576

5677
# List available tools
5778
print("## Tools")
58-
print(self.list_items((await self.session.list_tools()).tools))
79+
self.dump_info(await self.entity_info(self.session.list_tools, "tools"))
5980
print()
6081

6182
async def call_tool(

framework/mcp/test.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,3 +150,43 @@ def test_mcp_alchemy():
150150
assert b"Calling tool: schema_definitions" in p.stdout
151151
assert b"id: INTEGER, nullable" in p.stdout
152152
assert b"data: VARCHAR, nullable" in p.stdout
153+
154+
155+
@pytest.mark.skipif(sys.version_info < (3, 13), reason="requires Python 3.13+")
156+
def test_pg_mcp():
157+
"""
158+
Validate the PG-MCP server works well.
159+
160+
It is written in Python and uses pgasync.
161+
https://github.com/crate-workbench/pg-mcp
162+
"""
163+
164+
# FIXME: Manually invoke pre-installation step.
165+
p = run(f"sh example_pg_mcp_install.sh")
166+
assert p.returncode == 0, p.stderr
167+
168+
p = run(f"{sys.executable} example_pg_mcp.py")
169+
assert p.returncode == 0
170+
171+
# Validate output specific to the MCP server.
172+
assert b"Processing request of type" in p.stderr
173+
assert b"PromptListRequest" in p.stderr
174+
assert b"ListResourcesRequest" in p.stderr
175+
assert b"ListToolsRequest" in p.stderr
176+
assert b"CallToolRequest" in p.stderr
177+
178+
# Validate output specific to CrateDB.
179+
assert b"Calling tool: pg_query" in p.stdout
180+
assert b"mountain: Mont Blanc" in p.stdout
181+
182+
assert b"Calling tool: pg_explain" in p.stdout
183+
184+
assert b"Reading resource: pgmcp://" in p.stdout
185+
assert b"schema_name: blob" in p.stdout
186+
assert b"schema_name: doc" in p.stdout
187+
assert b"schema_name: sys" in p.stdout
188+
assert b"table_name: jobs" in p.stdout
189+
assert b"table_name: shards" in p.stdout
190+
191+
assert b"Getting prompt: nl_to_sql_prompt" in p.stdout
192+
assert b"You are an expert PostgreSQL database query assistant" in p.stdout

0 commit comments

Comments
 (0)