Skip to content

feat: add structured and file-backed SQL result handling - #45

Open
Dao-you wants to merge 7 commits into
RichardHan:mainfrom
Dao-you:json-output
Open

feat: add structured and file-backed SQL result handling#45
Dao-you wants to merge 7 commits into
RichardHan:mainfrom
Dao-you:json-output

Conversation

@Dao-you

@Dao-you Dao-you commented Jul 29, 2026

Copy link
Copy Markdown

Why

The existing query output joins columns and values with commas and newlines without escaping them.

This becomes ambiguous when database values contain commas, quotes, line breaks, SQL definitions, or user-provided text. MCP clients and language models may no longer be able to reliably distinguish columns, rows, and original values.

Large result sets are also returned inline in a single MCP response, which can consume a significant portion of the model context before the result can be inspected or filtered.

Summary

This PR adds safer and more predictable SQL result handling:

  • Adds opt-in JSON and properly quoted CSV output.
  • Keeps the existing legacy format as the default for backward compatibility.
  • Adds opt-in file-backed storage for oversized results.
  • Returns compact metadata and bounded preview rows instead of the full stored payload.
  • Provides bounded row pagination and byte-range chunk retrieval.
  • Adds configurable limits for inline responses, stored files, total storage, expiry, previews, and path exposure.
  • Adds an export CLI and a context-size benchmark.
  • Expands tests for serialization, compatibility, storage, retrieval, validation, and security.

New options

Environment variables

Option Purpose
MSSQL_RESULT_FORMAT Selects legacy, json, or csv output.
MSSQL_RESULT_OUTPUT_DIR Enables file-backed storage in the specified absolute directory.
MSSQL_RESULT_MAX_INLINE_ROWS Stores results exceeding the inline row limit.
MSSQL_RESULT_MAX_INLINE_BYTES Stores results exceeding the inline byte limit.
MSSQL_RESULT_PREVIEW_ROWS Limits preview rows in a stored-result summary.
MSSQL_RESULT_PREVIEW_BYTES Limits the serialized preview size.
MSSQL_RESULT_DEFAULT_PAGE_ROWS Sets the default stored-result page size.
MSSQL_RESULT_MAX_PAGE_ROWS Limits rows requested in one page.
MSSQL_RESULT_MAX_PAGE_BYTES Limits the serialized size of one page.
MSSQL_RESULT_MAX_PAGE_SOURCE_BYTES Prevents large stored files from being fully parsed for pagination.
MSSQL_RESULT_MAX_CHUNK_BYTES Limits bytes returned by one chunk read.
MSSQL_RESULT_TTL_SECONDS Sets how long stored results remain accessible.
MSSQL_RESULT_MAX_FILE_BYTES Limits the size of one stored result.
MSSQL_RESULT_MAX_STORE_BYTES Limits total storage and evicts older results when needed.
MSSQL_RESULT_INCLUDE_PATH Optionally includes the local result path in stored-result metadata.

MCP tool arguments

Option Purpose
result_format Overrides the configured format for one execute_sql call.
store_result Forces one result to be stored even when it is below the thresholds.
offset / limit Selects a bounded row page through get_sql_result.
offset_bytes / max_bytes Selects a bounded file chunk through read_sql_result_chunk.

Stored results return a server-generated result_id by default. This mode works without shared filesystem access and allows MCP clients to retrieve bounded pages or chunks.

When MSSQL_RESULT_INCLUDE_PATH=true, the result also includes result_path. This is intended for trusted local workflows where another host-side process can read the stored file directly.

Export CLI

export-sql-result exports a complete stored result by result_id or validates and exports an existing canonical result file.

Additional fixes

Result-returning statements are detected using cursor metadata rather than SQL text matching, improving support for CTEs, stored procedures, and statements such as UPDATE ... OUTPUT.

Database failures are also reported as MCP errors, with password-like values redacted, and database resources are closed reliably after failures.

Compatibility

The legacy result format remains the default.

JSON, CSV, and file-backed storage are opt-in. Existing configurations continue to behave as before unless the new settings or tool arguments are used.

Limitation

Query results are still fetched and serialized fully in server memory before the storage decision.

This change bounds MCP responses and prevents oversized payloads from entering the model context, but it does not provide streaming query processing.

Validation

python run_tests.py --suite all

Dao-you and others added 7 commits July 28, 2026 18:14
The existing output joins row values with commas and newlines, so any value
containing a delimiter, a quote or a line break produces a result the caller
cannot split back into rows reliably. NULL is also indistinguishable from the
literal string "None".

Add a serialization module offering three wire formats:

- legacy: the current delimiter-joined output, still the default
- json: compact columns/rows arrays; NULL stays null, Unicode and delimiters
  are preserved, and database-only types (Decimal, datetime, UUID, bytes)
  convert to stable strings
- csv: RFC 4180 quoting via the standard library csv module

Restrict the ruff test-file ignores to the lint rules the existing tests
already trip, so the suite lints without rewriting unrelated tests.
A large result set returned inline can exhaust the caller''s context window
before it can be examined. Add an opt-in store that writes a complete JSON
result to disk and lets the caller retrieve it in bounded pieces.

The store is disabled unless MSSQL_RESULT_OUTPUT_DIR names an absolute
directory. Results over either inline threshold are written atomically via a
temporary file and rename, and every retrieval path is bounded:

- row pages capped by both row count and exact serialized byte size
- byte-range chunks for results too large to page
- a source-size guard so an oversized file is never parsed into memory
- TTL expiry and a total-size quota, evicting oldest results first

Callers never supply a path. They receive a server-generated 32-character
hex ID, and every lookup is checked to resolve inside the configured
directory with symlinks refused.
Wire the serialization formats and the result store into the MCP server, and
correct three problems found while doing so.

Result handling:
- execute_sql accepts an optional result_format, defaulting to
  MSSQL_RESULT_FORMAT and then to legacy, so existing clients are unaffected
- oversized results are stored and answered with metadata, a checksum and
  bounded preview rows instead of the full rowset
- get_sql_result and read_sql_result_chunk are advertised only when storage is
  configured, and MSSQL_COMMAND may not shadow their names

Fixes:
- dispatch on cursor.description rather than by matching the query text, so
  CTEs, stored procedures and UPDATE ... OUTPUT are recognised as returning
  rows; every successful statement now commits, which previously did not
  happen for a write that also returned a rowset
- database failures are raised as MCP errors instead of being returned as a
  successful result whose body happens to describe an error, with the
  configured password and password-like fragments redacted
- connections and cursors close in a finally block, so a failure part way
  through no longer leaks them

Tests reach the registered handlers through a small adapter. Previously they
called the Server.call_tool decorator itself, which takes no arguments, so
those assertions never ran against the real handlers.
Add export-sql-result, which validates a stored result as a complete canonical
envelope and writes compact JSON to stdout, so a host-side process can consume
a stored result without going through MCP. Non-standard JSON constants such as
NaN are rejected rather than silently accepted.

Add a benchmark that reports the UTF-8 size of the legacy output, the inline
JSON output and a stored-result summary, so the context saving claimed for
oversized results can be measured rather than asserted.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant