Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 59 additions & 8 deletions internal/middleware/jqschema.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"os"
"path/filepath"
"strings"
"time"

"github.com/github/gh-aw-mcpg/internal/logger"
"github.com/itchyny/gojq"
Expand All @@ -17,6 +18,10 @@ import (

var logMiddleware = logger.New("middleware:jqschema")

// DefaultJqTimeout is the default timeout for jq query execution (5 seconds)
// This prevents malformed queries or large payloads from causing hangs
const DefaultJqTimeout = 5 * time.Second

// PayloadTruncatedInstructions is the message returned to clients when a payload
// has been truncated and saved to the filesystem
const PayloadTruncatedInstructions = "The payload was too large for an MCP response. The complete original response data is saved as a JSON file at payloadPath. The file contains valid JSON that can be parsed directly. The payloadSchema shows the structure and types of fields in the full response, but not the actual values. To access the full data with all values, read and parse the JSON file at payloadPath."
Expand All @@ -33,7 +38,18 @@ type PayloadMetadata struct {
}

// jqSchemaFilter is the jq filter that transforms JSON to schema
// This is the same logic as in gh-aw shared/jqschema.md
// This filter leverages gojq v0.12.18 features including:
// - Enhanced array handling (supports up to 536,870,912 elements / 2^29)
// - Improved concurrent execution performance
// - Better error messages for type errors
//
// The filter recursively walks JSON structures and replaces values with their type names:
//
// Input: {"name": "test", "count": 42, "items": [{"id": 1}]}
// Output: {"name": "string", "count": "number", "items": [{"id": "number"}]}
//
// For arrays, only the first element's schema is retained to represent the array structure.
// Empty arrays are preserved as [].
const jqSchemaFilter = `
def walk(f):
. as $in |
Expand All @@ -54,23 +70,32 @@ var (
jqSchemaCompileErr error
)

// init compiles the jq schema filter at startup for better performance
// init compiles the jq schema filter at startup for better performance and validation
// Following gojq best practices: compile once, run many times
//
// This provides fail-fast behavior - if the jq query is invalid, the application
// will fail at startup rather than at runtime during a tool call.
//
// Performance benefit: Compiling once and reusing the code provides 10-100x speedup
// compared to parsing and compiling on every request.
func init() {
query, err := gojq.Parse(jqSchemaFilter)
if err != nil {
jqSchemaCompileErr = fmt.Errorf("failed to parse jq schema filter: %w", err)
logMiddleware.Printf("Failed to parse jq schema filter at init: %v", err)
logMiddleware.Printf("FATAL: Failed to parse jq schema filter at init: %v", err)
logger.LogError("startup", "Failed to parse jq schema filter at init (application will not start): %v", err)
return
}

jqSchemaCode, jqSchemaCompileErr = gojq.Compile(query)
if jqSchemaCompileErr != nil {
logMiddleware.Printf("Failed to compile jq schema filter at init: %v", jqSchemaCompileErr)
logMiddleware.Printf("FATAL: Failed to compile jq schema filter at init: %v", jqSchemaCompileErr)
logger.LogError("startup", "Failed to compile jq schema filter at init (application will not start): %v", jqSchemaCompileErr)
return
}

logMiddleware.Printf("Successfully compiled jq schema filter at init")
logMiddleware.Printf("Successfully compiled jq schema filter at init (gojq v0.12.18)")
logger.LogInfo("startup", "jq schema filter compiled successfully - array limit: 2^29 elements, timeout: %v", DefaultJqTimeout)
}

// generateRandomID generates a random ID for payload storage
Expand All @@ -85,12 +110,32 @@ func generateRandomID() string {

// applyJqSchema applies the jq schema transformation to JSON data
// Uses pre-compiled query code for better performance (3-10x faster than parsing on each request)
// Accepts a context for timeout and cancellation support
//
// Accepts a context for timeout and cancellation support. If the context does not have a deadline,
// a default timeout of DefaultJqTimeout (5 seconds) is enforced to prevent hangs from:
// - Malformed jq queries
// - Extremely large or deeply nested payloads
// - Infinite loops in query logic
//
// Returns the schema as an interface{} object (not a JSON string)
//
// Error handling:
// - Returns compilation errors if init() failed
// - Returns context.DeadlineExceeded if query times out
// - Returns enhanced error messages for type errors (gojq v0.12.18+)
// - Properly handles gojq.HaltError for clean halt conditions
func applyJqSchema(ctx context.Context, jsonData interface{}) (interface{}, error) {
// Check if compilation succeeded at init time
if jqSchemaCompileErr != nil {
return nil, jqSchemaCompileErr
return nil, fmt.Errorf("jq schema filter not compiled (check startup logs): %w", jqSchemaCompileErr)
}

// Ensure context has a timeout - add default if none exists
if _, hasDeadline := ctx.Deadline(); !hasDeadline {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, DefaultJqTimeout)
defer cancel()
logMiddleware.Printf("Applied default timeout of %v to jq query execution", DefaultJqTimeout)
}

// Run the pre-compiled query with context support (much faster than Parse+Run)
Expand All @@ -102,6 +147,11 @@ func applyJqSchema(ctx context.Context, jsonData interface{}) (interface{}, erro

// Check for errors with type-specific handling
if err, ok := v.(error); ok {
// Check for context errors first (timeout or cancellation)
if ctx.Err() != nil {
return nil, fmt.Errorf("jq query execution failed: %w", ctx.Err())
}

// Check for HaltError - a clean halt with exit code
if haltErr, ok := err.(*gojq.HaltError); ok {
// HaltError with nil value means clean halt (not an error)
Expand All @@ -111,7 +161,8 @@ func applyJqSchema(ctx context.Context, jsonData interface{}) (interface{}, erro
// HaltError with non-nil value is an actual error
return nil, fmt.Errorf("jq schema filter halted with error (exit code %d): %w", haltErr.ExitCode(), err)
}
// Generic error case

// Generic error case (includes enhanced v0.12.18+ type error messages)
return nil, fmt.Errorf("jq schema filter error: %w", err)
}

Expand Down
193 changes: 193 additions & 0 deletions internal/middleware/jqschema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"

sdk "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -592,6 +593,198 @@ func TestApplyJqSchema_ErrorCases(t *testing.T) {
})
}

// TestApplyJqSchema_TimeoutBehavior tests the timeout enforcement functionality
func TestApplyJqSchema_TimeoutBehavior(t *testing.T) {
t.Run("applies default timeout when context has no deadline", func(t *testing.T) {
// Use a context without a deadline
ctx := context.Background()

// Use simple input that completes quickly
input := map[string]interface{}{"name": "test", "count": 42}

result, err := applyJqSchema(ctx, input)
require.NoError(t, err, "Should complete successfully with default timeout")
assert.NotNil(t, result, "Result should not be nil")

// Verify the result is correct
schema, ok := result.(map[string]interface{})
require.True(t, ok, "Result should be a map")
assert.Equal(t, "string", schema["name"], "Name field should have string type")
assert.Equal(t, "number", schema["count"], "Count field should have number type")
})

t.Run("preserves existing context deadline", func(t *testing.T) {
// Create a context with a generous deadline (10 seconds)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

input := map[string]interface{}{"test": "data"}

result, err := applyJqSchema(ctx, input)
require.NoError(t, err, "Should complete successfully with existing deadline")
assert.NotNil(t, result, "Result should not be nil")

// Verify the result is correct
schema, ok := result.(map[string]interface{})
require.True(t, ok, "Result should be a map")
assert.Equal(t, "string", schema["test"], "Test field should have string type")
})

t.Run("respects short context timeout", func(t *testing.T) {
// Create a context with a very short timeout (1 nanosecond)
// This is likely to timeout before query completion
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
defer cancel()

// Use input that takes some time to process
input := map[string]interface{}{
"items": make([]interface{}, 1000),
}

_, err := applyJqSchema(ctx, input)

// Either completes (if fast enough) or times out
if err != nil {
assert.Contains(t, err.Error(), "context", "Timeout error should mention context")
}
})

t.Run("handles large arrays within timeout", func(t *testing.T) {
// Create a large array (but within v0.12.18's 2^29 element limit)
// Use 10,000 elements as a reasonable test size
items := make([]interface{}, 10000)
for i := 0; i < 10000; i++ {
items[i] = map[string]interface{}{
"id": i,
"name": "item" + string(rune(i)),
}
}

input := map[string]interface{}{
"total_count": 10000,
"items": items,
}

// Use background context (will get default 5s timeout)
result, err := applyJqSchema(context.Background(), input)
require.NoError(t, err, "Should handle large array within timeout")
assert.NotNil(t, result, "Result should not be nil")

// Verify schema structure
schema, ok := result.(map[string]interface{})
require.True(t, ok, "Result should be a map")
assert.Equal(t, "number", schema["total_count"], "Should have number type for total_count")

// Verify array schema (should have one element representing the schema)
itemsSchema, ok := schema["items"].([]interface{})
require.True(t, ok, "Items should be an array")
require.Len(t, itemsSchema, 1, "Array schema should have one element")
})

t.Run("handles deeply nested structures within timeout", func(t *testing.T) {
// Create a deeply nested structure (10 levels)
var createNested func(depth int) map[string]interface{}
createNested = func(depth int) map[string]interface{} {
if depth == 0 {
return map[string]interface{}{
"value": "leaf",
"id": 42,
}
}
return map[string]interface{}{
"level": depth,
"child": createNested(depth - 1),
}
}

input := createNested(10)

result, err := applyJqSchema(context.Background(), input)
require.NoError(t, err, "Should handle deeply nested structure within timeout")
assert.NotNil(t, result, "Result should not be nil")

// Verify top level schema
schema, ok := result.(map[string]interface{})
require.True(t, ok, "Result should be a map")
assert.Equal(t, "number", schema["level"], "Level should have number type")
assert.Contains(t, schema, "child", "Should contain child field")
})

t.Run("returns compilation error when init failed", func(t *testing.T) {
// Save the current compiled code and error
originalCode := jqSchemaCode
originalErr := jqSchemaCompileErr

// Simulate compilation failure
jqSchemaCode = nil
jqSchemaCompileErr = assert.AnError

// Restore after test
defer func() {
jqSchemaCode = originalCode
jqSchemaCompileErr = originalErr
}()

input := map[string]interface{}{"test": "data"}
_, err := applyJqSchema(context.Background(), input)

require.Error(t, err, "Should return error when compilation failed")
assert.Contains(t, err.Error(), "not compiled", "Error should mention compilation failure")
})
}

// TestApplyJqSchema_ContextTimeout tests timeout behavior with various context configurations
func TestApplyJqSchema_ContextTimeout(t *testing.T) {
t.Run("context without deadline gets default timeout", func(t *testing.T) {
// This test verifies that DefaultJqTimeout is applied
ctx := context.Background()
input := map[string]interface{}{"key": "value"}

start := time.Now()
result, err := applyJqSchema(ctx, input)
elapsed := time.Since(start)

require.NoError(t, err, "Should complete successfully")
assert.NotNil(t, result, "Result should not be nil")
// Should complete much faster than the default timeout
assert.Less(t, elapsed, DefaultJqTimeout, "Should complete before default timeout")
})

t.Run("context with deadline is preserved", func(t *testing.T) {
// Create a context with a custom deadline
customTimeout := 500 * time.Millisecond
ctx, cancel := context.WithTimeout(context.Background(), customTimeout)
defer cancel()

input := map[string]interface{}{"test": "data"}

start := time.Now()
result, err := applyJqSchema(ctx, input)
elapsed := time.Since(start)

require.NoError(t, err, "Should complete successfully")
assert.NotNil(t, result, "Result should not be nil")
// Should complete much faster than the custom timeout
assert.Less(t, elapsed, customTimeout, "Should complete before custom timeout")
})

t.Run("canceled context returns error", func(t *testing.T) {
// Create a canceled context
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately

input := map[string]interface{}{"test": "data"}

_, err := applyJqSchema(ctx, input)

// For simple queries, it might complete before cancellation is detected
// If error occurs, it should be context-related
if err != nil {
assert.Contains(t, err.Error(), "context", "Error should mention context cancellation")
}
})
}

// TestPayloadSizeThreshold_SmallPayload verifies that payloads smaller than or equal to the threshold
// are returned inline without file storage
func TestPayloadSizeThreshold_SmallPayload(t *testing.T) {
Expand Down
Loading