diff --git a/CHANGES.md b/CHANGES.md index 94bdb4d..6841dac 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,8 @@ # CrateDB MCP changelog ## Unreleased +- Parameters: Stopped propagating port number as global variable +- Dependencies: Allowed updating to FastMCP 2.9 ## v0.0.3 - 2025-06-18 - Dependencies: Downgraded to `fastmcp<2.7` to fix a breaking change diff --git a/cratedb_mcp/cli.py b/cratedb_mcp/cli.py index 5b236e1..a3489b2 100644 --- a/cratedb_mcp/cli.py +++ b/cratedb_mcp/cli.py @@ -46,5 +46,7 @@ def serve(ctx: click.Context, transport: str, port: int) -> None: Start MCP server. """ logger.info(f"CrateDB MCP server starting with transport: {transport}") - mcp.settings.port = port - mcp.run(transport=t.cast(transport_types, transport)) + kwargs = {} + if transport in ["sse", "streamable-http"]: + kwargs = {"port": port} + mcp.run(transport=t.cast(transport_types, transport), **kwargs) diff --git a/pyproject.toml b/pyproject.toml index e13dfe8..d1fd2f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,7 +74,7 @@ dependencies = [ "cachetools<7", "click<9", "cratedb-about==0.0.5", - "fastmcp<2.7", + "fastmcp>=2,<2.10", "hishel<0.2", "pueblo==0.0.11", "sqlparse<0.6", @@ -88,8 +88,11 @@ optional-dependencies.develop = [ ] optional-dependencies.test = [ "pytest<9", + "pytest-asyncio<2", "pytest-cov<7", + "pytest-env<2", "pytest-mock<4", + "pytest-timeout<3", ] scripts.cratedb-mcp = "cratedb_mcp.cli:cli" @@ -147,6 +150,16 @@ pythonpath = [ xfail_strict = true markers = [ ] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "session" +asyncio_default_test_loop_scope = "session" +# filterwarnings = ["error::DeprecationWarning"] +timeout = 3 +env = [ + "FASTMCP_TEST_MODE=1", + 'D:FASTMCP_LOG_LEVEL=DEBUG', + 'D:FASTMCP_ENABLE_RICH_TRACEBACKS=0', +] [tool.coverage.paths] source = [ diff --git a/tests/test_cli.py b/tests/test_cli.py index a2f99cf..e1ad0e8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -79,7 +79,6 @@ def test_cli_valid_default(mocker, capsys): # Verify the outcome. assert run_mock.call_count == 1 assert run_mock.call_args == mock.call("stdio") - assert mcp.settings.port == 8000 def test_cli_valid_custom(mocker, capsys): @@ -100,8 +99,7 @@ def test_cli_valid_custom(mocker, capsys): # Verify the outcome. assert run_mock.call_count == 1 - assert run_mock.call_args == mock.call("streamable-http") - assert mcp.settings.port == 65535 + assert run_mock.call_args == mock.call("streamable-http", port=65535) def test_cli_invalid_transport_option(mocker, capsys): diff --git a/tests/test_examples.py b/tests/test_examples.py index d69836c..285b6e5 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -4,6 +4,7 @@ import pytest +@pytest.mark.timeout(15) def test_mcptools(): proc = subprocess.run(["examples/mcptools.sh"], capture_output=True, timeout=15, check=True) assert proc.returncode == 0 diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 51cc69b..a41b399 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -1,34 +1,65 @@ +import asyncio +import json + import pytest +from fastmcp import Client +from fastmcp.client import FastMCPTransport +from fastmcp.exceptions import ToolError + +from cratedb_mcp.__main__ import mcp + +transport = FastMCPTransport(mcp) +client = Client(transport=transport) + + +async def query_sql_async(sql: str) -> dict: + async with client: + response = await client.call_tool("query_sql", {"query": sql}) + return json.loads(response[0].text) + + +def query_sql(sql: str) -> dict: + return asyncio.run(query_sql_async(sql)) + + +async def call_tool_async(name: str, args: dict = None, decode_json: bool = False): + async with client: + response = await client.call_tool(name, args) + if decode_json: + return json.loads(response[0].text) + return response[0].text + -from cratedb_mcp.__main__ import ( - fetch_cratedb_docs, - get_cratedb_documentation_index, - get_health, - get_table_metadata, - query_sql, -) +def call_tool(name: str, args: dict = None, decode_json: bool = False): + return asyncio.run(call_tool_async(name, args, decode_json)) def test_get_documentation_index(): - assert len(get_cratedb_documentation_index()) >= 3 + assert len(call_tool("get_cratedb_documentation_index", decode_json=True)) >= 3 def test_fetch_docs_forbidden(): - with pytest.raises(ValueError) as ex: - fetch_cratedb_docs("https://example.com") + with pytest.raises(ToolError) as ex: + call_tool("fetch_cratedb_docs", {"link": "https://example.com"}) assert ex.match("Link is not permitted: https://example.com") def test_fetch_docs_permitted_github(): - response = fetch_cratedb_docs( - "https://raw.githubusercontent.com/crate/crate/refs/heads/5.10/docs/general/builtins/scalar-functions.rst" + response = call_tool( + "fetch_cratedb_docs", + { + "link": "https://raw.githubusercontent.com/crate/crate/refs/heads/5.10/docs/general/builtins/scalar-functions.rst" + }, ) assert "initcap" in response def test_fetch_docs_permitted_cratedb_com(): - response = fetch_cratedb_docs( - "https://cratedb.com/docs/crate/reference/en/latest/_sources/general/builtins/scalar-functions.rst.txt" + response = call_tool( + "fetch_cratedb_docs", + { + "link": "https://cratedb.com/docs/crate/reference/en/latest/_sources/general/builtins/scalar-functions.rst.txt" + }, ) assert "initcap" in response @@ -44,7 +75,7 @@ def test_query_sql_trailing_slash(mocker): def test_query_sql_forbidden_easy(): - with pytest.raises(PermissionError) as ex: + with pytest.raises(ToolError) as ex: assert "RelationUnknown" in str( query_sql("INSERT INTO foobar (id) VALUES (42) RETURNING id") ) @@ -52,14 +83,15 @@ def test_query_sql_forbidden_easy(): def test_query_sql_forbidden_sneak_value(): - with pytest.raises(PermissionError) as ex: + with pytest.raises(ToolError) as ex: query_sql("INSERT INTO foobar (operation) VALUES ('select')") assert ex.match("Only queries that have a SELECT statement are allowed") def test_get_table_metadata(): - assert "partitions_health" in str(get_table_metadata()) + assert "partitions_health" in str(call_tool("get_table_metadata")) def test_get_health(): - assert "missing_shards" in str(get_health()) + result = call_tool("get_health") + assert "missing_shards" in str(result)