diff --git a/.github/workflows/framework-mcp.yml b/.github/workflows/framework-mcp.yml index a708e9cd..f1a96918 100644 --- a/.github/workflows/framework-mcp.yml +++ b/.github/workflows/framework-mcp.yml @@ -68,6 +68,12 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@v5 + - name: Install Java + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '21' + - name: Install JBang uses: jbangdev/setup-jbang@main diff --git a/framework/mcp/README.md b/framework/mcp/README.md index 62d9d925..d84a0ad1 100644 --- a/framework/mcp/README.md +++ b/framework/mcp/README.md @@ -36,6 +36,10 @@ program. Server for JDBC] from the [quarkus-mcp-servers] package, providing a range of tools. It is written in Java, to be invoked with [JBang]. +- `example_mcp_alchemy.py`: Exercise communication using the [MCP Alchemy] MCP + server package, providing a range of tools. It is written in Python, and uses + [SQLAlchemy] and the [CrateDB SQLAlchemy dialect]. + ## Resources - Read a [brief introduction to MCP] by ByteByteGo. @@ -124,10 +128,12 @@ unlocking more details and features. [Claude Desktop configuration]: https://github.com/modelcontextprotocol/servers?tab=readme-ov-file#using-an-mcp-client [connecting to an already running MCP server]: https://github.com/modelcontextprotocol/python-sdk/issues/145 [CrateDB]: https://cratedb.com/database +[CrateDB SQLAlchemy dialect]: https://cratedb.com/docs/sqlalchemy-cratedb/ [DBHub]: https://github.com/bytebase/dbhub [Introduction to MCP]: https://modelcontextprotocol.io/introduction [JBang]: https://www.jbang.dev/ [MCP]: https://modelcontextprotocol.io/ +[MCP Alchemy]: https://github.com/runekaagaard/mcp-alchemy [MCP Python SDK]: https://github.com/modelcontextprotocol/python-sdk [MCP SSE]: https://github.com/sidharthrajaram/mcp-sse [Model Context Protocol (MCP) @ CrateDB]: https://github.com/crate/crate-clients-tools/discussions/234 @@ -137,5 +143,6 @@ unlocking more details and features. [npx]: https://docs.npmjs.com/cli/v11/commands/npx [oterm configuration]: https://ggozad.github.io/oterm/tools/mcp/ [quarkus-mcp-servers]: https://github.com/quarkiverse/quarkus-mcp-servers +[SQLAlchemy]: https://sqlalchemy.org/ [uv]: https://docs.astral.sh/uv/ [Writing MCP Clients]: https://github.com/modelcontextprotocol/python-sdk?tab=readme-ov-file#writing-mcp-clients diff --git a/framework/mcp/example_builtin.py b/framework/mcp/example_builtin.py index 66a1f6bf..e3bea5ef 100644 --- a/framework/mcp/example_builtin.py +++ b/framework/mcp/example_builtin.py @@ -40,14 +40,12 @@ async def run(): # Validate database content. db = DatabaseAdapter("crate://crate@localhost:4200/") - db.run_sql("CREATE TABLE IF NOT EXISTS public.testdrive (id INT, data TEXT)") - db.run_sql("INSERT INTO public.testdrive (id, data) VALUES (42, 'Hotzenplotz')") - db.refresh_table("public.testdrive") + db.run_sql("CREATE TABLE IF NOT EXISTS public.mcp_builtin (id INT, data TEXT)") + db.run_sql("INSERT INTO public.mcp_builtin (id, data) VALUES (42, 'Hotzenplotz')") + db.refresh_table("public.mcp_builtin") # Read a few resources. - # FIXME: Only works on schema=public, because the PostgreSQL adapter hard-codes `WHERE table_schema = 'public'`. - # https://github.com/bytebase/dbhub/blob/09424c8513c8c7bef7f66377b46a2b93a69a57d2/src/connectors/postgres/index.ts#L89-L107 - await client.read_resource("postgres://crate@localhost:5432/testdrive/schema") + await client.read_resource("postgres://crate@localhost:5432/mcp_builtin/schema") if __name__ == "__main__": diff --git a/framework/mcp/example_dbhub.py b/framework/mcp/example_dbhub.py index 664dd3f9..27fe74df 100644 --- a/framework/mcp/example_dbhub.py +++ b/framework/mcp/example_dbhub.py @@ -43,9 +43,9 @@ async def run(): # Validate database content. db = DatabaseAdapter("crate://crate@localhost:4200/") - db.run_sql("CREATE TABLE IF NOT EXISTS testdrive.dbhub (id INT, data TEXT)") - db.run_sql("INSERT INTO testdrive.dbhub (id, data) VALUES (42, 'Hotzenplotz')") - db.refresh_table("public.testdrive") + db.run_sql("CREATE TABLE IF NOT EXISTS testdrive.mcp_dbhub (id INT, data TEXT)") + db.run_sql("INSERT INTO testdrive.mcp_dbhub (id, data) VALUES (42, 'Hotzenplotz')") + db.refresh_table("testdrive.mcp_dbhub") # Read available resources. await client.read_resource("db://schemas") @@ -57,7 +57,7 @@ async def run(): "schema": "sys", }) await client.get_prompt("explain_db", arguments={"schema": "testdrive"}) - await client.get_prompt("explain_db", arguments={"schema": "testdrive", "table": "dbhub"}) + await client.get_prompt("explain_db", arguments={"schema": "testdrive", "table": "mcp_dbhub"}) if __name__ == "__main__": diff --git a/framework/mcp/example_jdbc.py b/framework/mcp/example_jdbc.py index 692074a1..2af3411d 100644 --- a/framework/mcp/example_jdbc.py +++ b/framework/mcp/example_jdbc.py @@ -42,8 +42,8 @@ async def run(): # await client.call_tool("list_tables", arguments={}) await client.call_tool("describe_table", arguments={"schema": "sys", "table": "summits"}) await client.call_tool("read_query", arguments={"query": "SELECT * FROM sys.summits ORDER BY height DESC LIMIT 3"}) - await client.call_tool("create_table", arguments={"query": "CREATE TABLE IF NOT EXISTS testdrive (id INT, data TEXT)"}) - await client.call_tool("write_query", arguments={"query": "INSERT INTO testdrive (id, data) VALUES (42, 'foobar')"}) + await client.call_tool("create_table", arguments={"query": "CREATE TABLE IF NOT EXISTS testdrive.mcp_jdbc (id INT, data TEXT)"}) + await client.call_tool("write_query", arguments={"query": "INSERT INTO testdrive.mcp_jdbc (id, data) VALUES (42, 'foobar')"}) # Get a few prompts. await client.get_prompt("er_diagram") diff --git a/framework/mcp/example_mcp_alchemy.py b/framework/mcp/example_mcp_alchemy.py new file mode 100644 index 00000000..7dd86a6e --- /dev/null +++ b/framework/mcp/example_mcp_alchemy.py @@ -0,0 +1,51 @@ +# MCP Alchemy Model Context Protocol Server for CrateDB +# https://github.com/runekaagaard/mcp-alchemy +# +# Derived from: +# https://github.com/modelcontextprotocol/python-sdk?tab=readme-ov-file#writing-mcp-clients +from cratedb_toolkit.util import DatabaseAdapter +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client +import where + +from mcp_utils import McpDatabaseConversation + + +async def run(): + # Create server parameters for stdio connection. + server_params = StdioServerParameters( + command=where.first("mcp-alchemy"), + args=[], + env={"DB_URL": "crate://crate@localhost:4200/?schema=testdrive"}, + ) + + async with stdio_client(server_params) as (read, write): + async with ClientSession( + read, write + ) as session: + # Initialize the connection. + await session.initialize() + + client = McpDatabaseConversation(session) + await client.inquire() + + print("## MCP server conversations") + print() + + # Provision database content. + db = DatabaseAdapter("crate://crate@localhost:4200/") + db.run_sql("CREATE TABLE IF NOT EXISTS mcp_alchemy (id INT, data TEXT)") + db.run_sql("INSERT INTO mcp_alchemy (id, data) VALUES (42, 'Hotzenplotz')") + db.refresh_table("mcp_alchemy") + + # Call a few tools. + await client.call_tool("execute_query", arguments={"query": "SELECT * FROM sys.summits ORDER BY height DESC LIMIT 3"}) + await client.call_tool("all_table_names", arguments={}) + await client.call_tool("filter_table_names", arguments={"q": "mcp"}) + await client.call_tool("schema_definitions", arguments={"table_names": ["mcp_alchemy"]}) + + +if __name__ == "__main__": + import asyncio + + asyncio.run(run()) diff --git a/framework/mcp/requirements.txt b/framework/mcp/requirements.txt index c062f719..ad749549 100644 --- a/framework/mcp/requirements.txt +++ b/framework/mcp/requirements.txt @@ -1,2 +1,5 @@ cratedb-toolkit -mcp<1.5 +mcp<1.6 +mcp-alchemy @ git+https://github.com/runekaagaard/mcp-alchemy.git@b85aae6; python_version>='3.12' +sqlalchemy-cratedb>=0.42.0.dev1 +where diff --git a/framework/mcp/test.py b/framework/mcp/test.py index cbcd3068..7d5b2910 100644 --- a/framework/mcp/test.py +++ b/framework/mcp/test.py @@ -4,6 +4,7 @@ import subprocess import sys +import pytest from cratedb_toolkit.util import DatabaseAdapter @@ -46,7 +47,7 @@ def test_builtin(): # Validate output specific to CrateDB. assert b"Calling tool: query" in p.stdout assert b"mountain: Mont Blanc" in p.stdout - assert b"Reading resource: postgres://crate@localhost:5432/testdrive/schema" in p.stdout + assert b"Reading resource: postgres://crate@localhost:5432/mcp_builtin/schema" in p.stdout assert b"column_name: id" in p.stdout assert b"data_type: integer" in p.stdout @@ -77,8 +78,8 @@ def test_jdbc(): # Validate database content. db = DatabaseAdapter("crate://crate@localhost:4200/") - db.refresh_table("doc.testdrive") - records = db.run_sql("SELECT * FROM doc.testdrive", records=True) + db.refresh_table("testdrive.mcp_jdbc") + records = db.run_sql("SELECT * FROM testdrive.mcp_jdbc", records=True) assert len(records) >= 1 assert records[0] == {"id": 42, "data": "foobar"} @@ -114,5 +115,38 @@ def test_dbhub(): assert b"- testdrive" in p.stdout assert b"Getting prompt: explain_db" in p.stdout - assert b"Table: dbhub in schema 'testdrive'" in p.stdout + assert b"Table: mcp_dbhub in schema 'testdrive'" in p.stdout assert b"Structure:\\n- id (integer)\\n- data (text)" in p.stdout + + +@pytest.mark.skipif(sys.version_info < (3, 12), reason="requires Python 3.12+") +def test_mcp_alchemy(): + """ + Validate the MCP Alchemy server works well. + + MCP Alchemy connects Claude Desktop directly to your databases. + MCP Alchemy is a MCP (model context protocol) server that gives the LLM access + to and knowledge about relational databases like SQLite, Postgresql, MySQL & + MariaDB, Oracle, MS-SQL, and CrateDB. + + It is written in Python and uses SQLAlchemy. + https://github.com/runekaagaard/mcp-alchemy + """ + p = run(f"{sys.executable} example_mcp_alchemy.py") + assert p.returncode == 0 + + # Validate output specific to the MCP server. + + # Validate output specific to CrateDB. + assert b"Calling tool: execute_query" in p.stdout + assert b"mountain: Mont Blanc" in p.stdout + + assert b"Calling tool: all_table_names" in p.stdout + assert b"mcp_alchemy" in p.stdout + + assert b"Calling tool: filter_table_names" in p.stdout + assert b"mcp_alchemy" in p.stdout + + assert b"Calling tool: schema_definitions" in p.stdout + assert b"id: INTEGER, nullable" in p.stdout + assert b"data: VARCHAR, nullable" in p.stdout