Skip to content
Open
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
9 changes: 9 additions & 0 deletions mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,15 @@ func (tn *ToolName) UnmarshalJSON(b []byte) error {
// Alias lets adapter authors use mcp.Markdown without importing the markdown subpackage.
type Markdown = markdown.Markdown

// DryRunIntegration is an optional interface that integrations can implement
// to provide native dry-run previews for mutation tools. When dry_run is set
// on an execute call, the server checks this interface first. If the integration
// returns (result, true), that result is used directly. Otherwise the server
// falls back to a simulated dry-run (arg validation + preview).
type DryRunIntegration interface {
DryRun(ctx context.Context, toolName ToolName, args map[string]any) (*ToolResult, bool)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

MarkdownIntegration.RenderMarkdown has a method-level doc explaining its return contract; DryRun doesn't. Worth adding a note that (nil, false) is the expected "decline" signal (not an error), so adapter authors don't reach for (*ToolResult{IsError: true}, true) when they just want to fall back:

// DryRun returns a native preview for the given tool call, or (nil, false)
// if this tool doesn't support native dry-run and the server should fall back
// to a simulated preview. Returning (nil, false) is not an error.
DryRun(ctx context.Context, toolName ToolName, args map[string]any) (*ToolResult, bool)

}

// MarkdownIntegration is an optional interface that integrations can implement
// to render tool responses as Markdown instead of JSON. The server calls
// RenderMarkdown in processResult before compaction — if it returns rendered
Expand Down
65 changes: 65 additions & 0 deletions server/dryrun.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package server

import (
"context"
"fmt"

mcp "github.com/daltoniam/switchboard"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
)

func (s *Server) handleDryRun(ctx context.Context, toolName mcp.ToolName, args map[string]any) (*mcpsdk.CallToolResult, error) {
integration, toolDef, err := s.findTool(toolName)
if err != nil {
return errorResult(err.Error()), nil
}

if err := validateArgs(toolDef, args); err != nil {
Comment thread
daltoniam marked this conversation as resolved.
return errorResult(fmt.Sprintf("dry-run validation failed: %s", err)), nil
}

cb := s.getBreaker(integration.Name())
if !cb.allow() {
cb.recordSuccess()

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This cb.recordSuccess() fires on the rejection path — allow() returned false, meaning the breaker is open. Resetting failures to zero here heals the breaker mid-rejection, so the next call passes through as if nothing happened.

This was flagged in the first review round but looks like it was lost in the squash. The fix is to remove this call and only keep the one at line 29:

if !cb.allow() {
    return errorResult(fmt.Sprintf(
        "dry-run: integration %q temporarily unavailable (circuit breaker open)",
        integration.Name(),
    )), nil
}
cb.recordSuccess()

return errorResult(fmt.Sprintf(
"dry-run: integration %q temporarily unavailable (circuit breaker open)",
integration.Name(),
)), nil
}
cb.recordSuccess()

if !integration.Healthy(ctx) {
return errorResult(fmt.Sprintf(
"dry-run: integration %q is unhealthy — call would likely fail",
integration.Name(),
)), nil
}

if dri, ok := integration.(mcp.DryRunIntegration); ok {
if result, handled := dri.DryRun(ctx, toolName, args); handled {
return &mcpsdk.CallToolResult{
Content: []mcpsdk.Content{
&mcpsdk.TextContent{Text: result.Data},
},
IsError: result.IsError,
}, nil
}
}

result, err := mcp.JSONResult(map[string]any{
"dry_run": true,
"tool": toolName,
"integration": integration.Name(),
"validated_args": args,
"status": "ok",
"note": "Simulated dry-run: arguments are valid, integration is healthy. This tool does not support native dry-run preview.",
})
if err != nil {
return errorResult(err.Error()), nil
}
return &mcpsdk.CallToolResult{
Content: []mcpsdk.Content{
&mcpsdk.TextContent{Text: result.Data},
},
}, nil
}
234 changes: 234 additions & 0 deletions server/dryrun_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
package server

import (
"context"
"encoding/json"
"testing"

mcp "github.com/daltoniam/switchboard"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

type mockDryRunIntegration struct {
mockIntegration
dryRunFn func(ctx context.Context, toolName mcp.ToolName, args map[string]any) (*mcp.ToolResult, bool)
}

func (m *mockDryRunIntegration) DryRun(ctx context.Context, toolName mcp.ToolName, args map[string]any) (*mcp.ToolResult, bool) {
if m.dryRunFn != nil {
return m.dryRunFn(ctx, toolName, args)
}
return nil, false
}

func dryRunRequest(toolName string, args map[string]any) *mcpsdk.CallToolRequest {
data, _ := json.Marshal(map[string]any{
"tool_name": toolName,
"arguments": args,
"dry_run": true,
})
return &mcpsdk.CallToolRequest{
Params: &mcpsdk.CallToolParamsRaw{
Name: "execute",
Arguments: json.RawMessage(data),
},
}
}

func TestDryRun_SimulatedFallback(t *testing.T) {
mi := &mockIntegration{
name: "github",
healthy: true,
tools: []mcp.ToolDefinition{
{
Name: "github_create_issue",
Description: "Create issue",
Parameters: map[string]string{"owner": "Owner", "repo": "Repo", "title": "Title"},
Required: []string{"owner", "repo", "title"},
},
},
}
s := setupTestServer(mi)
ctx := context.Background()

result, err := s.handleExecute(ctx, dryRunRequest("github_create_issue", map[string]any{
"owner": "daltoniam",
"repo": "switchboard",
"title": "Test issue",
}))
require.NoError(t, err)
require.False(t, result.IsError)

var resp map[string]any
require.NoError(t, json.Unmarshal([]byte(result.Content[0].(*mcpsdk.TextContent).Text), &resp))
assert.Equal(t, true, resp["dry_run"])
assert.Equal(t, "github_create_issue", resp["tool"])
assert.Equal(t, "github", resp["integration"])
assert.Equal(t, "ok", resp["status"])
args := resp["validated_args"].(map[string]any)
assert.Equal(t, "daltoniam", args["owner"])
}

func TestDryRun_ValidationFails(t *testing.T) {
mi := &mockIntegration{
name: "github",
healthy: true,
tools: []mcp.ToolDefinition{
{
Name: "github_create_issue",
Parameters: map[string]string{"owner": "Owner", "repo": "Repo", "title": "Title"},
Required: []string{"owner", "repo", "title"},
},
},
}
s := setupTestServer(mi)
ctx := context.Background()

result, err := s.handleExecute(ctx, dryRunRequest("github_create_issue", map[string]any{
"owner": "daltoniam",
}))
require.NoError(t, err)
assert.True(t, result.IsError)
assert.Contains(t, result.Content[0].(*mcpsdk.TextContent).Text, "dry-run validation failed")
assert.Contains(t, result.Content[0].(*mcpsdk.TextContent).Text, "repo")
}

func TestDryRun_NativeIntegration(t *testing.T) {
mi := &mockDryRunIntegration{
mockIntegration: mockIntegration{
name: "aws",
healthy: true,
tools: []mcp.ToolDefinition{
{
Name: "aws_lambda_invoke",
Parameters: map[string]string{"function_name": "Function"},
Required: []string{"function_name"},
},
},
},
dryRunFn: func(_ context.Context, _ mcp.ToolName, args map[string]any) (*mcp.ToolResult, bool) {
return &mcp.ToolResult{
Data: `{"dry_run":true,"native":true,"would_invoke":"` + args["function_name"].(string) + `"}`,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Unsafe type assertion here — if function_name is absent from args or the wrong type, this panics rather than failing the test cleanly. The validated_args assertions were updated to use the two-value form, worth doing the same here:

fn, ok := args["function_name"].(string)
require.True(t, ok, "expected function_name to be a string")
return &mcp.ToolResult{
    Data: `{"dry_run":true,"native":true,"would_invoke":"` + fn + `"}`,
}, true

}, true
},
}
s := setupTestServerWithIntegration(mi)
ctx := context.Background()

result, err := s.handleExecute(ctx, dryRunRequest("aws_lambda_invoke", map[string]any{
"function_name": "my-func",
}))
require.NoError(t, err)
require.False(t, result.IsError)

var resp map[string]any
require.NoError(t, json.Unmarshal([]byte(result.Content[0].(*mcpsdk.TextContent).Text), &resp))
assert.Equal(t, true, resp["native"])
assert.Equal(t, "my-func", resp["would_invoke"])
}

func TestDryRun_NativeDeclines_FallsBackToSimulated(t *testing.T) {
mi := &mockDryRunIntegration{
mockIntegration: mockIntegration{
name: "aws",
healthy: true,
tools: []mcp.ToolDefinition{
{
Name: "aws_s3_list",
Parameters: map[string]string{"bucket": "Bucket"},
Required: []string{"bucket"},
},
},
},
dryRunFn: func(_ context.Context, _ mcp.ToolName, _ map[string]any) (*mcp.ToolResult, bool) {
return nil, false
},
}
s := setupTestServerWithIntegration(mi)
ctx := context.Background()

result, err := s.handleExecute(ctx, dryRunRequest("aws_s3_list", map[string]any{
"bucket": "my-bucket",
}))
require.NoError(t, err)
require.False(t, result.IsError)

var resp map[string]any
require.NoError(t, json.Unmarshal([]byte(result.Content[0].(*mcpsdk.TextContent).Text), &resp))
assert.Equal(t, true, resp["dry_run"])
assert.Equal(t, "ok", resp["status"])
}

func TestDryRun_UnhealthyIntegration(t *testing.T) {
mi := &mockIntegration{
name: "github",
healthy: false,
tools: []mcp.ToolDefinition{
{Name: "github_create_issue", Parameters: map[string]string{"title": "Title"}, Required: []string{"title"}},
},
}
s := setupTestServer(mi)
ctx := context.Background()

result, err := s.handleExecute(ctx, dryRunRequest("github_create_issue", map[string]any{
"title": "test",
}))
require.NoError(t, err)
assert.True(t, result.IsError)
assert.Contains(t, result.Content[0].(*mcpsdk.TextContent).Text, "unhealthy")
}

func TestDryRun_ToolNotFound(t *testing.T) {
s := setupTestServer(&mockIntegration{name: "test", healthy: true})
ctx := context.Background()

result, err := s.handleExecute(ctx, dryRunRequest("nonexistent_tool", nil))
require.NoError(t, err)
assert.True(t, result.IsError)
assert.Contains(t, result.Content[0].(*mcpsdk.TextContent).Text, "not found")
}

func TestDryRun_DoesNotExecute(t *testing.T) {
executed := false
mi := &mockIntegration{
name: "github",
healthy: true,
tools: []mcp.ToolDefinition{
{Name: "github_create_issue", Parameters: map[string]string{"title": "Title"}, Required: []string{"title"}},
},
execFn: func(_ context.Context, _ mcp.ToolName, _ map[string]any) (*mcp.ToolResult, error) {
executed = true
return &mcp.ToolResult{Data: `{"id":1}`}, nil
},
}
s := setupTestServer(mi)
ctx := context.Background()

_, err := s.handleExecute(ctx, dryRunRequest("github_create_issue", map[string]any{
"title": "test",
}))
require.NoError(t, err)
assert.False(t, executed, "dry-run should not call Execute")
}

func TestDryRun_NoPinning(t *testing.T) {
mi := &mockIntegration{
name: "github",
healthy: true,
tools: []mcp.ToolDefinition{
{Name: "github_create_issue", Parameters: map[string]string{"title": "Title"}, Required: []string{"title"}},
},
}
s := setupTestServer(mi)
ctx := context.Background()

_, err := s.handleExecute(ctx, dryRunRequest("github_create_issue", map[string]any{
"title": "test",
}))
require.NoError(t, err)

sess := s.sessionStore.GetOrCreate("default")
assert.Equal(t, 0, sess.PinnedCount(), "dry-run should not pin results")
}
Comment thread
daltoniam marked this conversation as resolved.
9 changes: 9 additions & 0 deletions server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,10 @@ List issues with server-side projection (only id, title, labels — no manual .m
"type": "string",
"description": "ES5 JavaScript code to execute server-side. Use var (not let/const), function() (not =>), string + concatenation (not template literals). Use api.call(toolName, args, {fields: [...]}) to invoke tools with optional field projection. Return the final result. (mutually exclusive with tool_name)",
},
"dry_run": map[string]any{
"type": "boolean",
"description": "If true, validate arguments and show what would happen without executing. Works with tool_name only (not scripts).",
},
}, nil),
}

Expand Down Expand Up @@ -643,6 +647,7 @@ func (s *Server) handleExecute(ctx context.Context, req *mcpsdk.CallToolRequest)
ToolName mcp.ToolName `json:"tool_name"`
Arguments map[string]any `json:"arguments"`
Script string `json:"script"`
DryRun bool `json:"dry_run"`
}
if err := json.Unmarshal(req.Params.Arguments, &args); err != nil {
return errorResult("invalid arguments: " + err.Error()), nil
Expand All @@ -664,6 +669,10 @@ func (s *Server) handleExecute(ctx context.Context, req *mcpsdk.CallToolRequest)
args.Arguments = map[string]any{}
}

if args.DryRun {
return s.handleDryRun(ctx, args.ToolName, args.Arguments)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The args.Script check above this returns early before dry_run is ever evaluated — so { script: "api.call('github_delete_repo', ...)", dry_run: true } executes the script for real. The tool description says "Works with tool_name only (not scripts)" but there's no enforcement.

This guard was in an intermediate commit but appears to have been dropped in the squash:

if args.DryRun && args.Script != "" {
    return errorResult("dry_run is not supported with script — use tool_name"), nil
}

}

sess := sessionFromCtx(ctx)
if sess == nil {
sess = s.sessionStore.GetOrCreate(sessionIDFromReq(req.Session))
Expand Down
Loading