PluginWorld
Mc

mcp-server

MCP

Enables AI assistants to inspect and query a TPC-H sales warehouse through read-only, allowlisted catalog tools and schema resources, with ad-hoc SQL disabled by default. It enforces strict arguments and input-free errors, while a differential attack suite verifies that query protections hold across MCP transports.

@Prithv122 · MIT License · updated today

SECURITY

B

SCORE

60

STARS

▲ 0

PLUG IN

git clone https://github.com/Prithv122/mcp-server.git

See the README to configure this MCP server

README

MCP server over guarded warehouse queries

A Model Context Protocol server that gives an AI assistant read-only, allowlisted access to a TPC-H sales warehouse, and a differential attack suite that proves the query guards still hold when the caller is a model speaking JSON-RPC instead of a person calling a Python API.

CI

Live demo: not deployed — runs locally over stdio inside any MCP client Stack: Python 3.13 · mcp 2.x SDK · DuckDB 1.5.5 · pandas · uv · pytest


1. The problem

Analysts want to ask an assistant questions of the warehouse. The obvious way to allow that, a "run this SQL" tool, hands a language model (and anything that can inject text into its context) a database connection. query-service already solved this for a human client with four guard layers: bound values, allowlisted identifiers, a single-read-statement check by DuckDB's own parser, and a read-only connection with a row cap. This project exposes that service over MCP and asks whether the guards survive the new boundary: the same payload sent through the protocol must get the same verdict as a direct call, with zero writes, zero over-cap results, and error text that never repeats what the caller sent.

2. The data

Source TPC-H, generated locally by DuckDB's tpch extension (CALL dbgen(sf=0.1))
Size scale factor 0.1: 8 tables, 600,572 line items, 150,000 orders, 15,000 customers
Licence generated data; no download, no licence attached
Test fixture mcpquery.minitpch: the same 8 tables and column names with a few dozen deterministic rows, built offline, used by the tests and CI

3. Architecture

flowchart LR
    C[MCP client<br/>assistant or test] -- JSON-RPC over stdio --> S[GuardedServer<br/>strict arguments,<br/>input-free errors]
    S --> G[Gateway<br/>classify exceptions<br/>into stable codes]
    G --> Q[QueryService<br/>vendored from #15]
    Q --> L1[catalog: typed params,<br/>allowlisted identifiers]
    Q --> L3[one read statement,<br/>checked by DuckDB's parser]
    Q --> L4[read-only connection,<br/>row cap 100k]
    L4 --> W[(TPC-H<br/>DuckDB file)]
    A[attack suite] -. same payload .-> S
    A -. same payload .-> Q
Surface Name What it does
tool list_queries names and descriptions of the 4 reviewed catalog queries
tool describe_query(name) parameter names, types, defaults and allowed values
tool run_query(name, params, limit) runs a catalog query; returns {columns, rows, row_count, truncated, cached, elapsed_ms, limit}
tool run_sql(sql, limit) one read statement; only registered with --allow-adhoc
resource warehouse://schema tables and columns from information_schema on the read-only connection
resource catalog://queries the whole catalog with parameter specs
prompt revenue_report(region, year) a report request; both arguments allowlisted

Every rejection is is_error: true with a JSON body {"code", "message"}, code one of UNSAFE_QUERY, UNKNOWN_QUERY, BAD_PARAM, QUERY_FAILED, INTERNAL.

4. Key decisions & tradeoffs

Decision Chose Over Why
Reuse the guards vendored copy of #15 pinned by commit (9363ae5) a git dependency, or rewriting the guards the suite tests the guards as shipped; 900 auditable lines, no network at install
Unknown arguments rejected (BAD_PARAM) the SDK default of dropping them silently a misplaced region would otherwise return an unfiltered, plausible, wrong number
Error text templates filled from server-side facts only passing the guard's message through a model reads error text as instructions; the upstream messages quote the bad value and the SQL line
Ad-hoc SQL off by default, --allow-adhoc to register run_sql always on the catalog answers the common questions; a model should not get a SQL prompt unasked
Transport stdio only also Streamable HTTP HTTP means authentication and hosting; a local assistant launches the server as a subprocess
DuckDB pinned to 1.5.5 (#15's lock) latest 2.x the parser-based read check is engine behaviour; test it on the engine it shipped on
Success criterion pre-registered in EVAL_PLAN.md before the suite was written judging the suite after seeing it the payloads cannot be chosen to fit the result
File access connection opened with external access disabled and its configuration locked (fixed upstream in #15) trusting "read-only" read-only stops writes, not SELECT * FROM read_text('...'); the suite's first run proved it
limit type strict integer at the protocol layer the SDK's lax coercion lax mode turned "5" into 5 and true into 1 before the server's own check ran
Runaway queries interrupt at the limit, then abandon and replace the connection; at most two abandoned waiting for the interrupt some DuckDB work never checks for an interrupt; a caller must get an answer anyway

5. Results

All numbers below come from files in results/ and can be regenerated with the commands in §6. The warehouse is TPC-H scale factor 0.1 generated locally; the mini fixture is synthetic.

5.1 Attack suite: the first run failed, and that was the useful part

The pass rule was committed in EVAL_PLAN.md before the suite existed: 0 bypasses (no write, no result over the row cap, no payload the server accepts that the bare service rejects, no expect: reject payload accepted, and, added in Amendment 2, no result over the 4 MB size budget), 0 errors that repeat caller input, and a stable code on every tool-call rejection.

Run Payloads Bypasses Echoes Coded tool rejections Verdict
First run, mini fixture (reproduction) 94 5 (×2 transports) 0 126/126 FAIL
Amendment 2 first run, both warehouses 111 0 0 164/164 FAIL: a runaway query outlived the client timeout
Amendment 2 second run, TPC-H 111 0 0 – FAIL: the benign size control timed out on a loaded machine
Final, mini fixture (report) 111 0 0 165/165 PASS
Final, TPC-H sf 0.1 (report) 111 0 0 165/165 PASS

Every run uses both transports (in-process and a stdio subprocess). The final runs are at df38f15; the middle two rows are recorded in EVAL_PLAN.md Amendment 2, because their report files were overwritten by the reruns. The first-run report is a reproduction on a clean clone with the pre-fix server files; the original in-session output was not saved.

What the first run found, and what fixed it:

Payloads Found Where the hole was Fix
fs-01..fs-03 SELECT * FROM read_text('C:/Windows/win.ini'), read_csv(...) and glob('*') through run_sql returned rows: any file the process could read was readable the vendored #15 guards: a table function is one SELECT, so the parser check passes it, and a read-only connection only blocks writes upstream in #15 (9363ae5): enable_external_access = false, lock_configuration = true; re-vendored
lm-04, lm-05 limit: "5" ran as 5 rows, limit: true as 1 the SDK's lax validation coerced them before the server's own check strict integer type on limit

Two more problems surfaced outside the suite and were fixed before the rerun: the tool-selection eval (§5.3) showed that params: {"limit": 5} collided with QueryService.run(limit=...) and crashed as INTERNAL (a use_cache key would have reached the cache switch the same way), and testing the document-search resource showed that the SDK repeats an unknown resource URI in its error. The 8 payloads added after the first run are marked [A1] and listed in the plan's Amendment 1.

An independent review from a clean context then tried about 110 payloads by hand. It found no write, file read or row-cap bypass, and every number above matched results/, but it found what the suite had not tried, now covered by 9 [A2] payloads:

Found by the review Fix
no time limit: SELECT count(*) FROM range(10^11) held the connection, and every other call waited a per-query limit (10 s by default, 2 s in the suite) that interrupts DuckDB
the interrupt is not always honoured: building one 200M-element LIST noticed it after 148 s (this failed the next run) a query still running 1 s past the limit is abandoned: the caller gets the timeout error, a fresh connection serves the next call, and with two abandoned queries running new calls are refused
the row cap does not bound bytes: 100,000 rows of 2 KB came back as 202 MB rows are rendered within a 4 MB budget, truncated_by: "size"
LIST values lost elements through numpy's str(); BLOBs printed as bytearray(...) rendered as JSON arrays and hex
the runner counted client exceptions as rejections and could not fail an ok without row data both now fail the run; the echo check also tests SQL fragments and prompt names

In the final runs, MCP and the bare service agree on 175 of 189 comparable results (92.6%). All 14 disagreements (7 payloads × 2 transports) are the server being stricter on purpose, each tagged with its reason in the suite: string, boolean and fractional limits (lm-04..lm-06), arguments outside the tool's schema (ua-01, ua-03), a params key naming the service's own use_cache switch (ps-02), and an empty date window (ex-08). The echo check excludes words the server itself publishes (tool, parameter and query names); without that exclusion it counts 106 hits, all names like end_date or run_sql. One payload, a lone UTF-16 surrogate (ex-07), cannot be sent over stdio at all (the client's JSON encoder refuses it), so it is judged in-process only.

5.2 Latency: what the protocol costs

p50 in ms over 30 timed calls after a warm-up, TPC-H sf 0.1 (full table). Not a quiet machine: five other projects were being built at the same time, so compare within a row, not with other runs. Measured at 25bca0f, before the per-query time limit existed; the worker thread and timer it adds to every query, cached or not, are not in these numbers.

Call Rows returned Bare service, cached MCP in-process, cached MCP over stdio, cached MCP over stdio, cold
run_query: late_shipments 5 5.4 8.7 11.8 49.4
run_query: revenue_by_dimension 25 5.3 9.2 11.8 64.8
run_query: daily_order_volume 365 5.1 17.4 22.0 30.6
run_query: top_customers 1,000 6.5 57.2 56.4 165.1
list_queries – 0.0 1.8 5.3 –

The overhead is not the protocol hop, it is the rows. On a cache hit, the MCP layer adds about 3 ms to a 5-row answer and about 51 ms to a 1,000-row, 6-column one, almost all of it turning a DataFrame into JSON twice (structured content plus a text copy). The stdio pipe adds 0-5 ms per call. Starting a stdio server (spawn to a ready client) took 7-10 s here, so a client should keep one process alive, which is what desktop clients do.

5.3 Tool selection with a local model (secondary, not gated)

qwen2.5:7b-instruct (Q4, Ollama, temperature 0) got the tool list, the server instructions and the catalog, and one of 20 analyst questions frozen before the first call (results, tasks). Correct means the right query and every pinned parameter equal to the reference.

11/20 correct (Wilson 95% interval 0.34 to 0.74). It never picked the wrong query. Of the 9 misses:

  • 7 are the same mistake: an inclusive end date (1995-12-31 for "1995" instead of 1996-01-01), although the tool description says end_date is exclusive and one task says "both days included". The server accepted all 7 and returned a plausible answer missing the last day. No guard can catch that: the call is valid.
  • 1 added a filter the query does not have (rejected with BAD_PARAM).
  • 1 put limit inside params, which exposed the crash described in §5.1.

The run replays offline from the recorded responses; after the fixes the score is the same and the crash is a coded BAD_PARAM. With n = 20 the interval is wide, so this says nothing about other models, only that a 7B model reads a half-open date window the way people do.

5.4 What this does and does not show

  • It shows that, for these 111 payloads, the server's verdicts are at least as strict as the bare service's on both transports and on both warehouses, that nothing was written, and that no error repeated caller input. It does not prove that no other payload gets through: the first run found a class of hole (table functions) that the original #15 tests had not tried, and the review found another (resource exhaustion) that the suite had not tried.
  • Known limits, not fixed here:
    • The time limit bounds how long the caller waits, not the work. An abandoned query keeps using CPU and memory until it ends, and the limit covers the query, not turning its rows into JSON. A hard bound needs the query in a separate process or limits in the database itself. This bit in hosted CI: ex-04's abandoned copies exhausted a 16 GB runner, so CI runs the suite with --exclude ex-04 (listed in its report) and the full suite runs locally.
    • DECIMAL and HUGEINT values arrive as float64 (the vendored service converts results through pandas): exact to about 15 significant digits. TPC-H's largest sum here has 14.
    • With --allow-adhoc, SELECT * FROM duckdb_databases() shows the warehouse file's absolute path. Metadata functions are not blocked.
    • Prompt calls the SDK rejects before the prompt function runs (missing or mistyped arguments) come back as an uncoded Internal server error: 4 of the suite's 12 prompt rejections.
    • SELECT 1; -- comment is rejected (QUERY_FAILED): the upstream limit wrapper strips only a final semicolon. It fails closed.
  • The most damaging failure in this project is not an attack. It is a valid call with an off-by-one date that returns a wrong number with no error. §7 says what I would change.

6. How to run

Prerequisites: uv and Python 3.13 (uv installs it). Ollama only for the optional tool-selection eval.

git clone https://github.com/Prithv122/mcp-server.git
cd mcp-server
uv sync
uv run pytest                      # offline: uses the mini fixture
uv run mcpquery attack-suite       # the differential suite on the mini fixture, both transports (~16 GB+ RAM for ex-04)

The real warehouse and the measurements:

uv run mcpquery build-db --sf 0.1                                     # TPC-H, ~20 s; fetches DuckDB's tpch extension once
uv run mcpquery attack-suite --db data/warehouse.duckdb --out results/attacks/tpch_sf0.1.json
uv run mcpquery latency --db data/warehouse.duckdb --out results/latency_sf0.1.json
uv run mcpquery tool-eval --db data/warehouse.duckdb --replay         # re-scores the recorded model responses, no model needed

Use it from an MCP client: copy .mcp.json.example, set the repository path, and the client starts mcpquery serve over stdio. Add --allow-adhoc to the arguments to expose run_sql.

7. What I'd change at 100× scale

  • Make the date window impossible to misread. The biggest measured error is a valid call with a wrong answer. Replace start_date/end_date with a period argument (1995, 1995-Q3, 1995-03) the server expands, or return the resolved window in every result ("window": "1995-01-01 <= o_orderdate < 1995-12-31") so the mistake is visible in the answer.
  • Stream or page large results. Serialisation is the cost (51 ms for 1,000 rows here). At 100x the rows, return a page plus a cursor, send rows once instead of twice, and for analytics clients offer Arrow instead of JSON.
  • Move layer 4 to the database. A read-only DuckDB file on one machine becomes a warehouse role with SELECT grants on named views only, no file or network functions, and a statement timeout. The protocol layer keeps its checks; the database stops trusting them.
  • One server per user, not per process. stdio means one server process per client. Shared use needs the Streamable HTTP transport, authentication, per-user row limits and an audit log of every tool call, which is where the error-code contract pays off.
  • Bound the work, not just the wait. Run each ad-hoc query in a worker process with a memory cap that can be killed, or push the limits into the database (statement timeout, memory limit per role). The thread-and-abandon design here keeps the caller responsive but cannot reclaim an abandoned query's CPU and memory.
  • Run the attack suite against every engine upgrade. Layer 3 depends on DuckDB's parser and its statement types; a new DuckDB version could add a statement or table function the suite has not seen. The suite runs in CI on every push, so a dependency bump runs it too.

References

  • Model Context Protocol specification and the official Python SDK (mcp, MIT).
  • TPC-H benchmark specification (schema), via DuckDB's tpch extension.
  • query-service (#15), the guarded service this server exposes.

SIMILAR PLUGINS