diff --git a/CHANGES.md b/CHANGES.md index 22f7cd8..83b26af 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -9,6 +9,8 @@ introducing powerful client capabilities, server proxying & composition, OpenAPI/FastAPI integration, and more advanced features. See [FastMCP 2.0 and the Official MCP SDK]. +- README: Added recommendations to use a read-only database user + to prevent agents from modifying the database content [FastMCP 2.0 and the Official MCP SDK]: https://gofastmcp.com/getting-started/welcome#fastmcp-2-0-and-the-official-mcp-sdk diff --git a/README.md b/README.md index 7048484..3c7204a 100644 --- a/README.md +++ b/README.md @@ -181,16 +181,6 @@ Relevant information is pulled from , curated per
Tool names are: `get_cratedb_documentation_index`, `fetch_cratedb_docs` -### Security considerations - -**By default, the application will access the database in read-only mode.** - -We do not recommend letting LLM-based agents insert or modify data by itself. -As such, only `SELECT` statements are permitted and forwarded to the database. -All other operations will raise a `ValueError` exception, unless the -`CRATEDB_MCP_PERMIT_ALL_STATEMENTS` environment variable is set to a -truthy value. This is **not** recommended. - ### Install The configuration snippets for AI assistants are using the `uvx` launcher @@ -233,6 +223,23 @@ in seconds. The `CRATEDB_MCP_DOCS_CACHE_TTL` environment variable (default: 3600) defines the cache lifetime for documentation resources in seconds. +### Security considerations + +If you want to prevent agents from modifying data, i.e., permit `SELECT` statements +only, it is recommended to [create a read-only database user by using "GRANT DQL"]. +```sql +CREATE USER "read-only" WITH (password = 'YOUR_PASSWORD'); +GRANT DQL TO "read-only"; +``` +Then, include relevant access credentials in the cluster URL. +```shell +export CRATEDB_CLUSTER_URL="https://read-only:YOUR_PASSWORD@example.aks1.westeurope.azure.cratedb.net:4200" +``` +The MCP Server also prohibits non-SELECT statements on the application level. +All other operations will raise a `PermissionError` exception, unless the +`CRATEDB_MCP_PERMIT_ALL_STATEMENTS` environment variable is set to a +truthy value. + ### Operate Start MCP server with `stdio` transport (default). @@ -289,6 +296,7 @@ Version pinning is strongly recommended, especially if you use it as a library. [CrateDB]: https://cratedb.com/database [cratedb-about]: https://pypi.org/project/cratedb-about/ [cratedb-outline.yaml]: https://github.com/crate/about/blob/v0.0.4/src/cratedb_about/outline/cratedb-outline.yaml +[create a read-only database user by using "GRANT DQL"]: https://community.cratedb.com/t/create-read-only-database-user-by-using-grant-dql/2031 [development documentation]: https://github.com/crate/cratedb-mcp/blob/main/DEVELOP.md [example questions]: https://github.com/crate/about/blob/v0.0.4/src/cratedb_about/query/model.py#L17-L44 [examples folder]: https://github.com/crate/cratedb-mcp/tree/main/examples diff --git a/cratedb_mcp/__main__.py b/cratedb_mcp/__main__.py index 5ad8575..5e37484 100644 --- a/cratedb_mcp/__main__.py +++ b/cratedb_mcp/__main__.py @@ -27,7 +27,7 @@ def query_cratedb(query: str) -> list[dict]: ) def query_sql(query: str): if not sql_is_permitted(query): - raise ValueError("Only queries that have a SELECT statement are allowed.") + raise PermissionError("Only queries that have a SELECT statement are allowed.") return query_cratedb(query) diff --git a/examples/mcptools.sh b/examples/mcptools.sh index 7d0077c..4e145b0 100755 --- a/examples/mcptools.sh +++ b/examples/mcptools.sh @@ -14,9 +14,18 @@ set -euo pipefail # brew tap f/mcptools # brew install mcp uv +if ! command -v mcptools >/dev/null 2>&1; then + echo mcptools not installed, skipping. + echo "Skipped." + exit 0 +fi + # Some systems do not provide the `mcpt` alias. alias mcpt=mcptools +# Display available MCP tools. +mcpt tools uvx cratedb-mcp serve + # Explore the Text-to-SQL tools. mcpt call query_sql --params '{"query":"SELECT * FROM sys.summits LIMIT 3"}' uvx cratedb-mcp serve mcpt call get_table_metadata uvx cratedb-mcp serve diff --git a/tests/test_examples.py b/tests/test_examples.py index 87db71d..d69836c 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1,12 +1,12 @@ # ruff: noqa: S603, S607 import subprocess -from shutil import which import pytest -@pytest.mark.skipif(not which("mcptools"), reason="requires mcptools") def test_mcptools(): proc = subprocess.run(["examples/mcptools.sh"], capture_output=True, timeout=15, check=True) assert proc.returncode == 0 + if b"Skipped." in proc.stdout: + raise pytest.skip("mcptools not installed") assert b"Ready." in proc.stdout diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 1d09cc5..61d1fe8 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -38,7 +38,7 @@ def test_query_sql_permitted(): def test_query_sql_forbidden_easy(): - with pytest.raises(ValueError) as ex: + with pytest.raises(PermissionError) as ex: assert "RelationUnknown" in str( query_sql("INSERT INTO foobar (id) VALUES (42) RETURNING id") ) @@ -46,7 +46,7 @@ def test_query_sql_forbidden_easy(): def test_query_sql_forbidden_sneak_value(): - with pytest.raises(ValueError) as ex: + with pytest.raises(PermissionError) as ex: query_sql("INSERT INTO foobar (operation) VALUES ('select')") assert ex.match("Only queries that have a SELECT statement are allowed")