All four tools call execute
The four tools, exactly as pab1it0/adx-mcp-server registered them at commit 48b2933:
@mcp.tool(description="Executes a Kusto Query Language (KQL) query against the configured Azure Data Explorer database and returns the results as a list of dictionaries.")
async def execute_query(query: str) -> List[Dict[str, Any]]:
client = get_kusto_client()
result_set = client.execute(config.database, query)
...
@mcp.tool(description="Retrieves the schema information for a specified table in the Azure Data Explorer database, including column names, data types, and other schema-related metadata.")
async def get_table_schema(table_name: str) -> List[Dict[str, Any]]:
client = get_kusto_client()
query = f"{table_name} | getschema"
result_set = client.execute(config.database, query)
...
@mcp.tool(description="Retrieves a random sample of rows from the specified table in the Azure Data Explorer database. The sample_size parameter controls how many rows to return (default: 10).")
async def sample_table_data(table_name: str, sample_size: int = 10) -> List[Dict[str, Any]]:
client = get_kusto_client()
query = f"{table_name} | sample {sample_size}"
result_set = client.execute(config.database, query)
...
@mcp.tool(description="Retrieves table details including TotalRowCount, HotExtentSize")
async def get_table_details(table_name: str) -> List[Dict[str, Any]]:
client = get_kusto_client()
query = f".show table {table_name} details"
result_set = client.execute(config.database, query)
...
Four advertisements, four call sites. Three of them build a query string by interpolating a caller-supplied parameter. The fourth one names what is happening and accepts the parameter under its real label.
// is a KQL comment
The bug needs only one fact about KQL beyond what the f-strings already make clear: // starts a line comment, and KQL accepts it inside any pipeline.
f"{table_name} | getschema" with table_name = "sensitive_data | project Secret, Password | take 100 //" becomes:
sensitive_data | project Secret, Password | take 100 // | getschema
The | getschema is now commented out. The query runs as sensitive_data | project Secret, Password | take 100. The MCP client saw an auto-approved call to get_table_schema. The cluster received a free-form KQL query against a table the caller named themselves.
f".show table {table_name} details" with table_name = "users details\n.drop table critical_data" becomes:
.show table users details
.drop table critical_data details
KQL evaluates statements separated by newlines. The first statement returns table metadata for users. The second statement is a management command that destroys the named table. The MCP client saw an auto-approved call to get_table_details. The cluster received .drop table.
sample_table_data is the same shape with a | sample N suffix that the same // comments out.
The MCP approval policy reads the tool's name
MCP clients implement tool-call approval by reading what the server registered: tool name, tool description, and the parameters the model is about to pass. The MCP spec leaves the policy itself to the client, and the convention that has emerged is to gate by tool name. Tools whose name implies an arbitrary execution primitive (execute_query, run_command, eval) draw a per-call prompt. Tools whose name implies a constrained metadata primitive (get_*, list_*, sample_*, describe_*) get auto-approved, or get a "trust this tool forever" prompt that the operator clicks once and forgets about.
The convention works when the tool's body is constrained to the primitive its name advertises. Here it is not. get_table_schema advertises "schema retrieval for a named table" and implements "any KQL query the caller writes ending in | getschema, with the suffix removable via //." The advertisement is a docstring. The body is forty-three characters of client.execute(config.database, query).
A user who routes an assistant through this server, lets the model pick tools, and clicks "always allow get_table_schema" once because they assume schema retrieval is read-only metadata, has authorized arbitrary KQL against their cluster for the rest of the conversation. An auto-approve mode keyed on a get_* name pattern does the same thing on every call. A whitelist of safe tools, configured by the operator on first use, does the same thing.
The patch is the regex the tool name had been promising
Commit 0abe0ee, "Add input validation for table_name and sample_size parameters to prevent KQL injection attacks":
+_TABLE_NAME_PATTERN = re.compile(r'^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)*$')
+
+def validate_table_name(table_name: str) -> str:
+ if not table_name or not table_name.strip():
+ raise ValueError("Table name cannot be empty")
+ table_name = table_name.strip()
+ if not _TABLE_NAME_PATTERN.match(table_name):
+ raise ValueError(...)
+ return table_name
async def get_table_schema(table_name: str) -> List[Dict[str, Any]]:
+ table_name = validate_table_name(table_name)
...
async def sample_table_data(table_name: str, sample_size: int = 10) -> List[Dict[str, Any]]:
+ table_name = validate_table_name(table_name)
+ sample_size = validate_sample_size(sample_size)
...
async def get_table_details(table_name: str) -> List[Dict[str, Any]]:
+ table_name = validate_table_name(table_name)
...
The regex says: a KQL identifier, optionally dot-qualified. No pipes, no whitespace, no backslashes, no slashes, no newlines, no semicolons. That regex is what get_table_schema had been advertising in its description string for the entire lifetime of the project. The patch is the first time the body agreed.
Internal-only by convention, applied to MCP tool surfaces
We have named this shape before. A header documented as IPC-internal, read with security-relevant authority on every inbound request: Next.js's x-middleware-subrequest (CVE-2025-29927). A property name documented as a framework brand, queried with 'in' on attacker-controlled JSON: MikroORM's __raw (CVE-2026-34220). A header GitHub's gitrpcd treats as the source of authority for rails_env, derived from a string that also carries push options (CVE-2026-3854). A flat key=value session file where the Basic auth password handler is one of the writers (CVE-2026-41940). The pattern is internal-only-by-convention: a contract held by documentation, read by enforcement, broken at the input boundary.
The MCP version puts the boundary at the tool registry. The client trusts that the tool name maps to the tool body. The server's @mcp.tool(description=...) decorator is the contract. Nothing in the MCP spec, in the FastMCP framework, or in any MCP client's approval logic verifies that the body matches the description. The agreement is between the server author and a downstream operator they will never meet, mediated by a string that whoever wrote the decorator typed.
The awesome-mcp-servers list, which this PoC's author searched first, contains hundreds of servers wrapping cloud platforms, internal databases, code-execution sandboxes, and credential stores. Every one of them registers tools with the same name + description + body shape. The auto-approval policies operators rely on every day are reading the name. The body is whatever the author wrote that week.
Prompt injection turns the suffix into a remote primitive
The PoC's framing leans on AI-agent threat models, and the framing earns it. The MCP layer is where prompt injection meets infrastructure. The model reads a document, an email, a Jira ticket, a row from the same ADX cluster the server is wrapping. The text in that source instructs the model to call get_table_schema(table_name="...") with a payload. The MCP client, having been told once that get_table_schema is safe, auto-approves the call. The cluster runs the query.
This is content-is-command at the agent boundary, and the payload's destination is the same client.execute the operator never authorized. The interpreter that reads attacker content and emits commands is the model. The interpreter that reads those commands and runs them is KQL. Between them is a server whose "safe" tool names were the only thing standing in.
PoC: romain-deperne/CVE-2026-33980
The patch is a regex. Before the patch, the policy was a docstring.