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
2 changes: 2 additions & 0 deletions docs/docs/reference/project-files/rill-yaml.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,8 @@ _[object]_ - A map of key-value pairs for setting variables on your project. It

- **`rill.ai.max_time_range_days`** - _[integer]_ - Maximum time range allowed for AI tool queries, in days. Set to 0 for no limit. Default: 0.

- **`rill.ai.mcp_tolerant_args`** - _[boolean]_ - Tolerantly decode MCP tool-call arguments where object/array-typed fields arrive as JSON-encoded strings, to work around a serialization bug in some MCP clients. Default: true.

- **`rill.strict_resolver_properties`** - _[boolean]_ - Return an error when a resolver contains properties not recognized by its implementation. Default: false.

- **`rill.strict_model_properties`** - _[boolean]_ - Return an error when a model contains unmapped properties. Default: false.
Expand Down
61 changes: 61 additions & 0 deletions runtime/ai/mcp.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
package ai

import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"time"

"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/rilldata/rill/runtime/pkg/jsonschemautil"
"go.uber.org/zap"
)

Expand Down Expand Up @@ -80,6 +84,27 @@ func (s *Session) MCPServer(ctx context.Context) *mcp.Server {
}
})

// Tolerantly decode tool arguments where object/array-typed fields arrive as JSON-encoded strings.
// This works around a serialization bug in some MCP clients (see https://github.com/anthropics/claude-code/issues/25865).
// It runs before the SDK validates the arguments against the tool's input schema.
// It can be disabled with the rill.ai.mcp_tolerant_args instance config option (enabled by default).
// The internal LLM tool-call path (CallToolWithOptions) receives tool inputs as real JSON objects from the provider API,
// so it does not need this; if that ever changes, the coercion should be applied there too.
cfg, err := s.runner.Runtime.InstanceConfig(ctx, s.instanceID)
if err != nil && !errors.Is(err, ctx.Err()) {
s.logger.Warn("failed to resolve instance config; enabling tolerant MCP argument decoding by default", zap.Error(err))
}
if err != nil || cfg.AIMCPTolerantArgs {
srv.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler {
return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) {
if method == "tools/call" {
s.coerceMCPToolArgs(req)
}
return next(ctx, method, req)
Comment on lines +100 to +103

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the coercing is a little risky, maybe only do it as a fallback when the normal code actually fails to parse? E.g. could be something like this:

Suggested change
if method == "tools/call" {
s.coerceMCPToolArgs(req)
}
return next(ctx, method, req)
res, err := next(ctx, method, req)
if method != "tools/call" || err == nil {
return res, err
}
var jsonrpcErr *jsonrpc.Error
if !errors.As(err, &jsonrpcErr) || jsonrpcErr.Code != jsonrpc.CodeInvalidParams {
return res, err
}
if !s.coerceMCPToolArgs(req) {
return res, err
}
return next(ctx, method, req)

}
})
}

// Add only the tools that the user has access to
ctx = WithSession(ctx, s)
for _, t := range s.runner.Tools {
Expand All @@ -99,6 +124,42 @@ func (s *Session) MCPServer(ctx context.Context) *mcp.Server {
return srv
}

// coerceMCPToolArgs rewrites a tool call's raw arguments,
// JSON-decoding any string values in positions where the tool's input schema expects an object or array.
// It fails open: on any lookup or decode failure it leaves the arguments untouched,
// so the SDK's schema validation produces its normal error.
func (s *Session) coerceMCPToolArgs(req mcp.Request) {
params, ok := req.GetParams().(*mcp.CallToolParamsRaw)
if !ok || len(params.Arguments) == 0 {
return
}
t, ok := s.runner.Tools[params.Name]
if !ok || t.Spec == nil {
return
}
schema, ok := t.Spec.InputSchema.(*jsonschema.Schema)
if !ok || schema == nil {
return
}

var args any
dec := json.NewDecoder(bytes.NewReader(params.Arguments))
dec.UseNumber() // preserve integer fidelity across the re-marshal
if err := dec.Decode(&args); err != nil {
return
}

coerced, changed := jsonschemautil.CoerceStringifiedJSON(schema, args)
if !changed {
return
}
data, err := json.Marshal(coerced)
if err != nil {
return
}
params.Arguments = data
}

// InternalError represents an internal error in a tool call.
// This is needed because by default, downstream logic (such as the MCP middleware) treats errors returned from tool handlers as user errors, not internal errors.
type InternalError struct {
Expand Down
3 changes: 3 additions & 0 deletions runtime/drivers/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,8 @@ type InstanceConfig struct {
AIMaxTimeRangeDays int64 `mapstructure:"rill.ai.max_time_range_days"`
// AIMaxMessageSizeBytes is the maximum allowed size of an AI message's contents (tool call args or results). Exceeding it results in an error.
AIMaxMessageSizeBytes int64 `mapstructure:"rill.ai.max_message_size_bytes"`
// AIMCPTolerantArgs indicates whether MCP tool-call arguments with object/array fields JSON-encoded as strings are tolerantly decoded.
AIMCPTolerantArgs bool `mapstructure:"rill.ai.mcp_tolerant_args"`
// StrictResolverProps indicates whether to return an error when a resolver contains properties that are not recognized by the resolver implementation.
StrictResolverProps bool `mapstructure:"rill.strict_resolver_properties"`
// StrictModelProps indicates whether to return an error when a model contains unmapped properties.
Expand Down Expand Up @@ -223,6 +225,7 @@ func (i *Instance) Config() (InstanceConfig, error) {
AIMaxQueryLimit: 250,
AIRequireTimeRange: true,
AIMaxMessageSizeBytes: 200 * 1024, // 200 KB
AIMCPTolerantArgs: true,
ModelPartitionsWarnOnFailure: i.Environment == "prod",
ModelTestsWarnOnFailure: i.Environment == "prod",
}
Expand Down
3 changes: 3 additions & 0 deletions runtime/parser/schema/rillyaml.schema.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,9 @@ allOf:
rill.ai.max_time_range_days:
type: integer
description: "Maximum time range allowed for AI tool queries, in days. Set to 0 for no limit. Default: 0."
rill.ai.mcp_tolerant_args:
type: boolean
description: "Tolerantly decode MCP tool-call arguments where object/array-typed fields arrive as JSON-encoded strings, to work around a serialization bug in some MCP clients. Default: true."
rill.strict_resolver_properties:
type: boolean
description: "Return an error when a resolver contains properties not recognized by its implementation. Default: false."
Expand Down
Loading
Loading