Skip to content

Commit 772f9d7

Browse files
authored
feat: validate format param, surface in tool prose, add opt-in JSON unwrap (#58)
* feat: validate format parameter, surface in tool prose, add opt-in JSON unwrap - Reject invalid format values with a clear error naming accepted values (json, csv, markdown) instead of silently falling through to JSON default - Add format and unwrap_json documentation to trino_query and trino_execute tool description prose so MCP agents discover them without reading param schemas - Add opt-in unwrap_json parameter that parses single-row, single-VARCHAR-column JSON results into an unwrapped_result field, reducing double-decode overhead for table functions like raw_query - Extract shared formatOutput/validateFormat/tryUnwrapJSON helpers to format.go Closes #57 * fix: address all review findings from PR #58 Bug fixes: - unwrap_json now renders the QueryOutput (with unwrapped_result) into the TextContent so the LLM actually sees clean JSON in its context, not just in the typed structured output - formatOutput uses explicit case "json" instead of default, with an error-returning default for unsupported formats Design fixes: - tryUnwrapJSON rejects bare JSON scalars (strings, numbers, booleans, null) — only objects and arrays are meaningful to unwrap - Accept CHAR, CHAR(N), and JSON column types in addition to VARCHAR for unwrap detection via new isStringColumnType helper - Validate trino_explain type parameter with same pattern as format: invalid values return a clear error instead of silently defaulting Code quality: - Extract renderTextContent helper to reduce cyclomatic complexity in handleQuery (18→14) and handleExecute (17→13) - Extract output format string constants to satisfy goconst linter - Move nolint:lll to end of same line (conventional placement) - Revert unnecessary struct field alignment in QueryOutput - Simplify JSON comparison in tests (direct string comparison) Test additions: - unwrap_json with CSV format: structured output has unwrapped_result, CSV text does not - Scalar JSON values ("hello", 42, true, null) are not unwrapped - Invalid explain type returns error - Text content assertions for unwrap_json (verifies issue #1 fix) * fix: unwrap JSON inline in rows instead of sidecar field (Option 2) Redesign unwrap_json to mutate the QueryOutput in place rather than adding a separate unwrapped_result field. When the flag is on and the result matches (1 row, 1 string-type column, valid JSON object/array): - columns[0].type is changed to "JSON" as an unambiguous signal - rows[0][col] is replaced with the parsed object - the envelope shape is identical to the non-unwrap case This eliminates the double-payload problem (old design serialized both the escaped string in rows AND the parsed object in unwrapped_result), the schema divergence (old design rendered QueryOutput vs QueryResult depending on the flag), and delivers the actual token-cost reduction that motivated the feature. Specific changes: - Remove UnwrappedResult from QueryOutput and renderTextContent helper - Add unwrapJSONColumn that mutates QueryOutput in place - Move formatCSV/formatMarkdown to format.go, change to accept *QueryOutput - Add stringifyValue helper so CSV/markdown render maps as compact JSON - Add columnTypeJSON constant for the "JSON" type signal - Update descriptions to say "single-string-column" (matches CHAR/JSON too) - Rewrite all unwrap tests to assert column type and row value, not sidecar * fix: description mismatch, shared slice mutation, code organization - Fix ToolExecute description to match ToolQuery: "single-string-column" with full column-type-changes-to-JSON language (was still saying "single-VARCHAR-column" from the first commit) - Shallow-copy the row map in unwrapJSONColumn before mutation to avoid modifying the shared client.QueryResult.Rows slice - Move escapeCSV from query.go to format.go alongside its only caller - Remove dead explain_test.go cases for inputs now rejected by validation
1 parent 18313e8 commit 772f9d7

9 files changed

Lines changed: 1070 additions & 163 deletions

File tree

pkg/tools/descriptions.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,22 @@ var defaultDescriptions = map[ToolName]string{
99
"excessive data transfer — use the limit parameter to control result size. " +
1010
"For large tables, always include WHERE clauses to filter results. " +
1111
"Consider using trino_explain first for expensive queries. " +
12+
"Results are returned as JSON by default. Pass format=csv for CSV output " +
13+
"(more token-efficient for large result sets) or format=markdown for a pipe-table. " +
14+
"Set unwrap_json=true to automatically parse single-row, single-string-column " +
15+
"results containing a JSON object or array — the column type changes to JSON " +
16+
"and the value becomes the parsed object (common with table functions like raw_query). " +
1217
"For write operations (INSERT, CREATE, etc.), use trino_execute instead.",
1318

1419
ToolExecute: "Execute a SQL statement against Trino, including write operations " +
1520
"(INSERT, UPDATE, DELETE, CREATE, DROP, ALTER, etc.). Returns affected row " +
1621
"counts or query results. Use trino_query for read-only SELECT queries. " +
17-
"This tool should be used when you need to modify data or schema.",
22+
"This tool should be used when you need to modify data or schema. " +
23+
"Results are returned as JSON by default. Pass format=csv for CSV output " +
24+
"(more token-efficient for large result sets) or format=markdown for a pipe-table. " +
25+
"Set unwrap_json=true to automatically parse single-row, single-string-column " +
26+
"results containing a JSON object or array — the column type changes to JSON " +
27+
"and the value becomes the parsed object (common with table functions like raw_query).",
1828

1929
ToolExplain: "Get the execution plan for a SQL query to understand performance characteristics " +
2030
"before running expensive queries. Use this when querying large tables (millions of " +

pkg/tools/execute.go

Lines changed: 21 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package tools
22

33
import (
44
"context"
5-
"encoding/json"
65
"fmt"
76
"time"
87

@@ -25,6 +24,11 @@ type ExecuteInput struct {
2524
// Format is the output format: json (default), csv, or markdown.
2625
Format string `json:"format,omitempty" jsonschema_description:"Output format: json, csv, or markdown (default: json)"`
2726

27+
// UnwrapJSON controls automatic JSON unwrapping for single-row, single-string-column results.
28+
// When true and the result matches, the column type is changed to "JSON" and the row value
29+
// is replaced with the parsed object. The envelope shape is unchanged.
30+
UnwrapJSON bool `json:"unwrap_json,omitempty" jsonschema_description:"When true and result is a single-row single-string-column containing a JSON object or array, parse the value inline and set column type to JSON"` //nolint:lll // jsonschema_description must be a single tag value
31+
2832
// Connection is the named connection to use. Empty uses the default connection.
2933
// Use trino_list_connections to see available connections.
3034
Connection string `json:"connection,omitempty" jsonschema_description:"Named connection to use (see trino_list_connections)"`
@@ -68,6 +72,11 @@ func (t *Toolkit) handleExecute(ctx context.Context, _ *mcp.CallToolRequest, inp
6872
return ErrorResult("sql parameter is required"), nil, nil
6973
}
7074

75+
// Validate format
76+
if err := validateFormat(input.Format); err != nil {
77+
return ErrorResult(err.Error()), nil, nil
78+
}
79+
7180
// Apply query interceptors (no read-only enforcement — that's the point of trino_execute)
7281
sql, err := t.InterceptSQL(ctx, input.SQL, ToolExecute)
7382
if err != nil {
@@ -117,32 +126,24 @@ func (t *Toolkit) handleExecute(ctx context.Context, _ *mcp.CallToolRequest, inp
117126
notifyProgress(ctx, notifier, 1, 3,
118127
fmt.Sprintf("Statement returned %d rows, formatting...", result.Stats.RowCount))
119128

120-
// Format output
121-
format := input.Format
122-
if format == "" {
123-
format = "json"
129+
// Build structured output (reuse QueryOutput — same result shape)
130+
queryOutput := buildQueryOutput(result)
131+
132+
// Attempt JSON unwrap if requested — mutates queryOutput in place,
133+
// changing columns[0].type to "JSON" and replacing the row value.
134+
if input.UnwrapJSON {
135+
unwrapJSONColumn(&queryOutput)
124136
}
125137

126-
var output string
127-
switch format {
128-
case "csv":
129-
output = formatCSV(result)
130-
case "markdown":
131-
output = formatMarkdown(result)
132-
default:
133-
data, err := json.MarshalIndent(result, "", " ")
134-
if err != nil {
135-
return ErrorResult(fmt.Sprintf("Failed to marshal result: %v", err)), nil, nil
136-
}
137-
output = string(data)
138+
// Format output
139+
output, err := formatOutput(&queryOutput, input.Format)
140+
if err != nil {
141+
return ErrorResult(err.Error()), nil, nil
138142
}
139143

140144
// Send progress notification: complete
141145
notifyProgress(ctx, notifier, 2, 3, "Execution complete")
142146

143-
// Build structured output (reuse QueryOutput — same result shape)
144-
queryOutput := buildQueryOutput(result)
145-
146147
return &mcp.CallToolResult{
147148
Content: []mcp.Content{
148149
&mcp.TextContent{Text: output},

pkg/tools/explain.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,11 @@ func (t *Toolkit) handleExplain(ctx context.Context, _ *mcp.CallToolRequest, inp
5959
return ErrorResult("sql parameter is required"), nil, nil
6060
}
6161

62+
// Validate explain type
63+
if err := validateExplainType(input.Type); err != nil {
64+
return ErrorResult(err.Error()), nil, nil
65+
}
66+
6267
// Apply query interceptors
6368
sql, err := t.InterceptSQL(ctx, input.SQL, ToolExplain)
6469
if err != nil {

pkg/tools/explain_test.go

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -79,16 +79,9 @@ func TestExplainInput_TypeMapping(t *testing.T) {
7979
inputType: "",
8080
expectedType: client.ExplainLogical,
8181
},
82-
{
83-
name: "unknown defaults to logical",
84-
inputType: "unknown",
85-
expectedType: client.ExplainLogical,
86-
},
87-
{
88-
name: "LOGICAL uppercase defaults to logical",
89-
inputType: "LOGICAL",
90-
expectedType: client.ExplainLogical, // Case sensitive, defaults
91-
},
82+
// "unknown" and "LOGICAL" are now rejected by validateExplainType
83+
// before reaching the switch. Invalid types are tested in
84+
// TestHandleExplain_InvalidType in handlers_test.go.
9285
}
9386

9487
for _, tt := range tests {

0 commit comments

Comments
 (0)