Summary: ctx_database_query hands the caller's SQL to Drupal's
Connection::prefixTables() and Connection::quoteIdentifiers(), which are
blind str_replace() passes over the whole statement. Every [,
], { and } inside a quoted string literal is rewritten
into an identifier quote before the statement reaches the database.
Sometimes that is a syntax error. Often it is a valid query that answers a different
question, and the tool returns the wrong answer as a normal result — no error, no warning,
and a result with exactly the shape a correct answer would have. An MCP client has no way to
notice.
Steps to reproduce
Any Drupal 11 site with ctx enabled. All statements below are self-contained.
-- 1. What the substitution actually does SELECT '[]' AS a, '[abc]' AS b, '[' AS c, '{tbl}' AS d; -- tool: a = "" b = "abc" c = " d = "tbl" -- psql: a = [] b = [abc] c = [ d = {tbl} -- 2. Silent wrong answer: regex character class and quantifier SELECT 'x' ~ '^[a-z]{1}$' AS m; -- tool: false psql: true SELECT 'abc123' ~ '[0-9]+' AS m; -- tool: false psql: true SELECT 'abc123' ~ '[[:digit:]]' AS m; -- tool: false psql: true -- 3. Silent wrong answer: JSON literal SELECT '{"a":1}' AS j; -- tool: "a":1" psql: {"a":1} -- 4. Noisy failure, and the clearest evidence: the error echoes the rewritten SQL SELECT 'abc123' ~ '[0-9]+' AS c, (ARRAY[1,2,3])[2] AS s; -- ERROR: syntax error at or near ""1,2,3"" -- LINE 1: SELECT 'abc123' ~ '"0-9"+' AS c, (ARRAY"1,2,3")"2"...
Case 1 shows this is not bracket-pair matching but a character-for-character replacement: a
lone [ becomes a lone ".
Root cause
QueryTool::__invoke() calls the database layer with default options:
// ctx/src/Plugin/McpTool/Database/QueryTool.php:94, :97 $statement = $this->connection->query($query, $normalized); // ... $statement = $this->connection->prepareStatement($query, [], TRUE);
Connection::prepareStatement() then runs both rewrites
(core/lib/Drupal/Core/Database/Connection.php:488-491):
// Resolve {tables} and [identifiers] to the platform specific syntax. $query = $this->prefixTables($query); if (!($options['allow_square_brackets'] ?? FALSE)) { $query = $this->quoteIdentifiers($query); }
Both are unconditional string replacements over the entire statement
(Connection.php:364 and :388):
return str_replace(['{', '}'], $this->tablePlaceholderReplacements, $sql); return str_replace(['[', ']'], $this->identifierQuotes, $sql);
That is correct for Drupal's own query builders, which own the SQL they generate and never put
a brace or a bracket in a literal. It is wrong for a tool whose purpose is to execute SQL somebody
else wrote.
Two independent rewrites are involved, and only one can be switched off:
[]— suppressible via theallow_square_bracketsoption.{}— not suppressible.prefixTables()
has no opt-out, and{table}expansion is an advertised feature of this tool.
Affected SQL
| SQL | Rewritten to | Outcome |
|---|---|---|
'[]', '[{"a":1}]' — JSON array literals |
'""' |
silent wrong answer |
'{"a":1}' — JSON object literals |
'"a":1"' |
silent wrong answer |
'[0-9]+', '[[:digit:]]' — regex classes |
'"0-9"+' |
silent wrong answer |
'x{2,3}' — regex quantifiers |
'x"2,3"' |
silent wrong answer |
'{1,2,3}'::int[] — array literals |
'"1,2,3"'::int"" |
error |
ARRAY[1,2], col[1] — constructors, subscripts |
ARRAY"1,2" |
error |
int[], text[] — array type casts |
int"" |
error |
'$[0].month' — jsonpath |
'$"0".month' |
error (read as a jsonpath variable) |
Every row was run through the tool; "silent wrong answer" means the tool returned a normal
result that disagrees with psql on the same statement. The error rows are harmless —
they are loud, and the message echoes the rewritten SQL. The silent rows are the bug.
Workaround
Never put a literal containing [ ] { } in the
SQL text. Bind it as a parameter — placeholders are substituted by PDO after the rewrites,
so their values are untouched:
query: SELECT 'x' ~ :re AS m arguments: {"re": "^[a-z]{1}$"} → true (correct; the same regex inline returns false) query: SELECT :v AS bound arguments: {"v": "[]"} → [] (correct; the same literal inline returns "")
This does not help with array subscripts, ARRAY[…] constructors or
type[] casts, which are syntax and cannot be parameterised.
Proposed fix
Pass allow_square_brackets from the tool:
$statement = $this->connection->query($query, $normalized, ['allow_square_brackets' => TRUE]); // ... $statement = $this->connection->prepareStatement($query, ['allow_square_brackets' => TRUE], TRUE);
That removes the entire [ ] half, which is the larger and more
dangerous one — JSON literals, regex classes, array syntax, jsonpath. The cost is that Drupal's
portable [identifier] quoting stops working inside this tool, which seems acceptable:
the tool's own description advertises only :name placeholders and {table}
expansion and never mentions [identifier], a caller who wants a quoted identifier can
write the database's own syntax, and silent data corruption is a strictly worse failure than
losing an undocumented convenience. If the portable syntax is worth keeping, exposing it as a tool
parameter defaulting to true would work as well.
The { } behaviour cannot be fixed the same way and should be
documented instead: braces are reserved for table-prefix expansion, so a literal brace must be
passed as a bound argument. Both the tool description and the query
property description are currently silent about this, as they are about brackets.
Environment
- Drupal core 11.3.14
- drupal/ctx 1.0.0-beta7, drupal/mcp_core 1.0.0-beta12, mcp/sdk v0.8.1
- PHP 8.4.22, PostgreSQL 17.7, empty table prefix
Not PostgreSQL-specific: identifierQuotes is " on every ANSI-quoting
driver, and the MySQL driver uses backticks outside ANSI_QUOTES mode, which produces the same class
of corruption.
Comments
Comment #4
niklan