Skip to content

Commit 7e732da

Browse files
authored
feat: add support for structured output (#11)
- Add recursive formatSchema for nested Python type annotations (list[str], {"key": type}) - Store raw OutputSchema and ParamInfo.Schema for rich type display in signatures - Return structured content as Monty dicts in execute_code - Add E2E test for structured output field access - Table-driven validateMontyValue tests, dedent helper for code blocks
1 parent e482658 commit 7e732da

5 files changed

Lines changed: 587 additions & 193 deletions

File tree

internal/app/server_test.go

Lines changed: 142 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -214,9 +214,12 @@ func TestE2EMultipleSkills(t *testing.T) {
214214
})
215215

216216
t.Run("execute_code_call_tool", func(t *testing.T) {
217+
code := dedent(`
218+
execute_sql(sql="SELECT 1")
219+
`)
217220
result, err := session.CallTool(ctx, &mcp.CallToolParams{
218221
Name: "execute_code",
219-
Arguments: map[string]any{"code": `execute_sql(sql="SELECT 1")`},
222+
Arguments: map[string]any{"code": code},
220223
})
221224
if err != nil {
222225
t.Fatal(err)
@@ -240,11 +243,11 @@ func TestE2EMultipleSkills(t *testing.T) {
240243
})
241244

242245
t.Run("execute_code_multi_tool", func(t *testing.T) {
243-
code := `
244-
a = execute_sql(sql="SELECT 1")
245-
b = read_file(path="/tmp/test.txt")
246-
a + " | " + b
247-
`
246+
code := dedent(`
247+
a = execute_sql(sql="SELECT 1")
248+
b = read_file(path="/tmp/test.txt")
249+
a + " | " + b
250+
`)
248251
result, err := session.CallTool(ctx, &mcp.CallToolParams{
249252
Name: "execute_code",
250253
Arguments: map[string]any{"code": code},
@@ -329,9 +332,12 @@ func TestE2EPositionalArgs(t *testing.T) {
329332
session := connectTestClient(t, ctx, mgr)
330333

331334
t.Run("positional_arg", func(t *testing.T) {
335+
code := dedent(`
336+
execute_sql("SELECT 1")
337+
`)
332338
result, err := session.CallTool(ctx, &mcp.CallToolParams{
333339
Name: "execute_code",
334-
Arguments: map[string]any{"code": `execute_sql("SELECT 1")`},
340+
Arguments: map[string]any{"code": code},
335341
})
336342
if err != nil {
337343
t.Fatal(err)
@@ -351,9 +357,12 @@ func TestE2EPositionalArgs(t *testing.T) {
351357
})
352358

353359
t.Run("keyword_arg", func(t *testing.T) {
360+
code := dedent(`
361+
execute_sql(sql="SELECT 2")
362+
`)
354363
result, err := session.CallTool(ctx, &mcp.CallToolParams{
355364
Name: "execute_code",
356-
Arguments: map[string]any{"code": `execute_sql(sql="SELECT 2")`},
365+
Arguments: map[string]any{"code": code},
357366
})
358367
if err != nil {
359368
t.Fatal(err)
@@ -427,9 +436,12 @@ func TestE2EToolNameConflict(t *testing.T) {
427436
})
428437

429438
t.Run("execute_code_prefixed_name", func(t *testing.T) {
439+
code := dedent(`
440+
alpha_search(q="test")
441+
`)
430442
result, err := session.CallTool(ctx, &mcp.CallToolParams{
431443
Name: "execute_code",
432-
Arguments: map[string]any{"code": `alpha_search(q="test")`},
444+
Arguments: map[string]any{"code": code},
433445
})
434446
if err != nil {
435447
t.Fatal(err)
@@ -449,9 +461,12 @@ func TestE2EToolNameConflict(t *testing.T) {
449461
})
450462

451463
t.Run("execute_code_unique_name", func(t *testing.T) {
464+
code := dedent(`
465+
unique_tool()
466+
`)
452467
result, err := session.CallTool(ctx, &mcp.CallToolParams{
453468
Name: "execute_code",
454-
Arguments: map[string]any{"code": `unique_tool()`},
469+
Arguments: map[string]any{"code": code},
455470
})
456471
if err != nil {
457472
t.Fatal(err)
@@ -471,6 +486,123 @@ func TestE2EToolNameConflict(t *testing.T) {
471486
})
472487
}
473488

489+
func TestE2EStructuredOutput(t *testing.T) {
490+
t.Parallel()
491+
ctx := t.Context()
492+
493+
type WeatherInput struct {
494+
Location string `json:"location" jsonschema:"city name"`
495+
}
496+
type WeatherOutput struct {
497+
Temperature float64 `json:"temperature"`
498+
Conditions string `json:"conditions"`
499+
}
500+
501+
ds := mcp.NewServer(&mcp.Implementation{Name: "weather-server"}, nil)
502+
mcp.AddTool(
503+
ds,
504+
&mcp.Tool{Name: "get_weather", Description: "Get weather data"},
505+
func(ctx context.Context, req *mcp.CallToolRequest, input WeatherInput) (*mcp.CallToolResult, WeatherOutput, error) {
506+
return &mcp.CallToolResult{}, WeatherOutput{
507+
Temperature: 22.5,
508+
Conditions: "Partly cloudy",
509+
}, nil
510+
},
511+
)
512+
513+
dsServerT, dsClientT := mcp.NewInMemoryTransports()
514+
go func() { _ = ds.Run(ctx, dsServerT) }()
515+
dsClient := mcp.NewClient(&mcp.Implementation{Name: "test"}, nil)
516+
dsSession, err := dsClient.Connect(ctx, dsClientT, nil)
517+
if err != nil {
518+
t.Fatal(err)
519+
}
520+
521+
srv, err := mcpserver.NewServerFromSession(ctx, dsSession)
522+
if err != nil {
523+
t.Fatal(err)
524+
}
525+
mgr, err := mcpserver.NewManagerFromServers(map[string]*mcpserver.Server{"weather": srv})
526+
if err != nil {
527+
t.Fatal(err)
528+
}
529+
defer mgr.Close()
530+
531+
session := connectTestClient(t, ctx, mgr)
532+
533+
t.Run("access_string_field", func(t *testing.T) {
534+
code := dedent(`
535+
w = get_weather(location="NYC")
536+
w["conditions"]
537+
`)
538+
result, err := session.CallTool(ctx, &mcp.CallToolParams{
539+
Name: "execute_code",
540+
Arguments: map[string]any{"code": code},
541+
})
542+
if err != nil {
543+
t.Fatal(err)
544+
}
545+
if result.IsError {
546+
tc := result.Content[0].(*mcp.TextContent)
547+
t.Fatalf("error: %s", tc.Text)
548+
}
549+
tc := result.Content[0].(*mcp.TextContent)
550+
if tc.Text != "Partly cloudy" {
551+
t.Errorf("expected 'Partly cloudy', got %q", tc.Text)
552+
}
553+
})
554+
555+
t.Run("access_numeric_field", func(t *testing.T) {
556+
code := dedent(`
557+
w = get_weather(location="NYC")
558+
w["temperature"]
559+
`)
560+
result, err := session.CallTool(ctx, &mcp.CallToolParams{
561+
Name: "execute_code",
562+
Arguments: map[string]any{"code": code},
563+
})
564+
if err != nil {
565+
t.Fatal(err)
566+
}
567+
if result.IsError {
568+
tc := result.Content[0].(*mcp.TextContent)
569+
t.Fatalf("error: %s", tc.Text)
570+
}
571+
tc := result.Content[0].(*mcp.TextContent)
572+
if tc.Text != "22.5" {
573+
t.Errorf("expected '22.5', got %q", tc.Text)
574+
}
575+
})
576+
}
577+
578+
// dedent strips the common leading whitespace from all non-empty lines.
579+
func dedent(s string) string {
580+
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
581+
582+
// Find minimum indentation across non-empty lines.
583+
minIndent := -1
584+
for _, line := range lines {
585+
trimmed := strings.TrimLeft(line, " \t")
586+
if trimmed == "" {
587+
continue
588+
}
589+
indent := len(line) - len(trimmed)
590+
if minIndent < 0 || indent < minIndent {
591+
minIndent = indent
592+
}
593+
}
594+
if minIndent <= 0 {
595+
return strings.TrimSpace(s)
596+
}
597+
598+
for i, line := range lines {
599+
if len(line) >= minIndent {
600+
lines[i] = line[minIndent:]
601+
}
602+
}
603+
return strings.TrimSpace(strings.Join(lines, "\n"))
604+
}
605+
474606
func splitOnce(s, sep string) []string {
475607
i := strings.Index(s, sep)
476608
if i < 0 {

0 commit comments

Comments
 (0)