From 35b48287328df00b954d321b08181187ccc5cfd0 Mon Sep 17 00:00:00 2001 From: Felix Geelhaar Date: Fri, 10 Jul 2026 23:00:04 +0200 Subject: [PATCH] feat(mcp): advertise output schemas for data tools + bump to v1.22 Adopt mcp-go structured content for fixed-schema data tools. Each converted tool now emits typed structuredContent alongside its JSON text block via ToolBuilder.OutputSchema, following the in-repo precedent set by observe / network_summary / aria_violations. Object-returning tools (schema added, no behavior change): - network_requests, web_vitals, console_errors, check_readiness, discover_form Bare-slice tools wrapped in a fixed {items,total} object envelope (a JSON object is required for structuredContent): - failed_requests, list_tabs, cookies_list Intentionally skipped (cannot honor a fixed schema): - accessibility_tree: returns a plain string, not an object - component_state, app_state: return map[string]any (framework/page-dependent) - all dynamic-extraction and browser-action tools (out of scope) Folds in the pending mcp v1.18->v1.22 bump (supersedes #64). Adds TestOutputSchemasGenerate: calls schema.Generate for every advertised output type so a type that becomes unrepresentable fails CI instead of silently dropping the tool at registration. Claude-Session: https://claude.ai/code/session_01LCyhyAffdzBmzG3yPPqzTT --- cmd/scout/mcp.go | 55 ++++++++++++++++++++++++++---- cmd/scout/mcp_outputschema_test.go | 50 +++++++++++++++++++++++++++ go.mod | 2 +- go.sum | 4 +-- 4 files changed, 102 insertions(+), 9 deletions(-) create mode 100644 cmd/scout/mcp_outputschema_test.go diff --git a/cmd/scout/mcp.go b/cmd/scout/mcp.go index c6fb1a2..41166af 100644 --- a/cmd/scout/mcp.go +++ b/cmd/scout/mcp.go @@ -226,6 +226,29 @@ type NetworkRequestsResult struct { Hint string `json:"hint,omitempty"` } +// FailedRequestsResult wraps the failed_requests list in a JSON object so the +// tool can advertise a fixed output schema (structuredContent must be an object, +// not a bare array). Each element is a fixed-shape agent.NetworkFailure. +type FailedRequestsResult struct { + Items []agent.NetworkFailure `json:"items"` + Total int `json:"total"` +} + +// ListTabsResult wraps the list_tabs array in a JSON object envelope so the tool +// can advertise a fixed output schema. Each element is a fixed-shape agent.TabInfo. +type ListTabsResult struct { + Items []agent.TabInfo `json:"items"` + Total int `json:"total"` +} + +// CookiesListResult wraps the cookies_list array in a JSON object envelope so the +// tool can advertise a fixed output schema. Each element is a fixed-shape +// agent.CookieInfo (values redacted). +type CookiesListResult struct { + Items []agent.CookieInfo `json:"items"` + Total int `json:"total"` +} + type NetworkSummaryInput struct { Pattern string `json:"pattern,omitempty" jsonschema:"description=Optional URL substring filter."` } @@ -964,6 +987,7 @@ WORKFLOW: navigate first, then use other tools. Use 'dismiss_cookies' after navi srv.Tool("network_requests"). ReadOnly(). + OutputSchema(NetworkRequestsResult{}). Description("Get captured network requests/responses including request bodies (POST/PUT/PATCH) and response bodies (max 32KB each, truncated if larger). Includes recent buffered requests so you can inspect traffic even if capture was enabled late. If the list is empty and capture was never enabled, the response includes a hint pointing at enable_network_capture."). Handler(func(ctx context.Context, input NetworkRequestsInput) (*NetworkRequestsResult, error) { out := s().CapturedRequests(input.Pattern) @@ -1044,9 +1068,14 @@ WORKFLOW: navigate first, then use other tools. Use 'dismiss_cookies' after navi srv.Tool("cookies_list"). ReadOnly(). + OutputSchema(CookiesListResult{}). Description("List all cookies for the active page (names + flags only — values are redacted). Useful for diagnosing stale-session issues after a backend restart."). - Handler(func(ctx context.Context, input ObserveInput) ([]agent.CookieInfo, error) { - return s().ListCookies() + Handler(func(ctx context.Context, input ObserveInput) (*CookiesListResult, error) { + cookies, err := s().ListCookies() + if err != nil { + return nil, err + } + return &CookiesListResult{Items: cookies, Total: len(cookies)}, nil }) srv.Tool("cookies_clear"). @@ -1074,6 +1103,7 @@ WORKFLOW: navigate first, then use other tools. Use 'dismiss_cookies' after navi srv.Tool("check_readiness"). ReadOnly(). + OutputSchema(agent.PageReadiness{}). Description("Check how ready the page is for interaction. Returns a 0-100 score, pending XHR count, skeleton/spinner presence, and suggestions for what to wait for."). Handler(func(ctx context.Context, input ObserveInput) (*agent.PageReadiness, error) { return s().CheckReadiness() @@ -1081,6 +1111,7 @@ WORKFLOW: navigate first, then use other tools. Use 'dismiss_cookies' after navi srv.Tool("web_vitals"). ReadOnly(). + OutputSchema(agent.WebVitalsResult{}). Description("Extract Core Web Vitals (LCP, CLS, INP) and performance timing (TTFB, First Paint, DOM Content Loaded). Each metric is rated good/needs-improvement/poor per Google thresholds."). Handler(func(ctx context.Context, input ObserveInput) (*agent.WebVitalsResult, error) { return s().WebVitals() @@ -1159,6 +1190,7 @@ WORKFLOW: navigate first, then use other tools. Use 'dismiss_cookies' after navi srv.Tool("console_errors"). ReadOnly(). + OutputSchema(agent.DiagnosticsResult{}). Description("Get captured console.error / console.warn messages plus recent network 4xx/5xx failures. Auto-installs lightweight network observers, so failures recorded after the first call surface here without an explicit enable_network_capture."). Handler(func(ctx context.Context, input ObserveInput) (*agent.DiagnosticsResult, error) { return s().Diagnostics() @@ -1166,9 +1198,14 @@ WORKFLOW: navigate first, then use other tools. Use 'dismiss_cookies' after navi srv.Tool("failed_requests"). ReadOnly(). + OutputSchema(FailedRequestsResult{}). Description("Recent network requests with status >= 400 (4xx/5xx). URL + method + status + response body snippet. Use after a form submission that silently failed."). - Handler(func(ctx context.Context, input ObserveInput) ([]agent.NetworkFailure, error) { - return s().FailedRequests() + Handler(func(ctx context.Context, input ObserveInput) (*FailedRequestsResult, error) { + failures, err := s().FailedRequests() + if err != nil { + return nil, err + } + return &FailedRequestsResult{Items: failures, Total: len(failures)}, nil }) srv.Tool("detect_auth_wall"). @@ -1233,6 +1270,7 @@ WORKFLOW: navigate first, then use other tools. Use 'dismiss_cookies' after navi srv.Tool("discover_form"). ReadOnly(). + OutputSchema(agent.FormDiscoveryResult{}). Description("Discover form fields with their labels, types, and CSS selectors."). Handler(func(ctx context.Context, input DiscoverFormInput) (*agent.FormDiscoveryResult, error) { return s().DiscoverForm(input.Selector) @@ -1266,9 +1304,14 @@ WORKFLOW: navigate first, then use other tools. Use 'dismiss_cookies' after navi srv.Tool("list_tabs"). ReadOnly(). + OutputSchema(ListTabsResult{}). Description("List all open tabs with their names, URLs, and titles."). - Handler(func(ctx context.Context, input ObserveInput) ([]agent.TabInfo, error) { - return s().ListTabs() + Handler(func(ctx context.Context, input ObserveInput) (*ListTabsResult, error) { + tabs, err := s().ListTabs() + if err != nil { + return nil, err + } + return &ListTabsResult{Items: tabs, Total: len(tabs)}, nil }) // --- Frames --- diff --git a/cmd/scout/mcp_outputschema_test.go b/cmd/scout/mcp_outputschema_test.go new file mode 100644 index 0000000..6127fd5 --- /dev/null +++ b/cmd/scout/mcp_outputschema_test.go @@ -0,0 +1,50 @@ +package main + +import ( + "testing" + + "go.klarlabs.de/mcp/schema" + "go.klarlabs.de/scout/agent" +) + +// TestOutputSchemasGenerate guards every type advertised via ToolBuilder.OutputSchema. +// +// OutputSchema runs schema.Generate at registration time; if it errors the +// ToolBuilder silently drops the tool. This test fails loudly instead, so a +// type that becomes unrepresentable (e.g. an added field with no JSON schema +// mapping) is caught in CI rather than silently removing a production tool. +func TestOutputSchemasGenerate(t *testing.T) { + cases := []struct { + name string + val any + }{ + // Newly advertised data-tool output types. + {"network_requests", NetworkRequestsResult{}}, + {"web_vitals", agent.WebVitalsResult{}}, + {"console_errors", agent.DiagnosticsResult{}}, + {"check_readiness", agent.PageReadiness{}}, + {"discover_form", agent.FormDiscoveryResult{}}, + {"failed_requests", FailedRequestsResult{}}, + {"list_tabs", ListTabsResult{}}, + {"cookies_list", CookiesListResult{}}, + // Pre-existing advertised types — kept here so the guard covers the + // full set of production output schemas. + {"observe", agent.Observation{}}, + {"submit_outcome", agent.SubmitOutcome{}}, + {"wait_for_navigation", agent.NavigationOutcome{}}, + {"aria_violations", agent.AriaViolationReport{}}, + {"network_summary", agent.NetworkSummary{}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + s, err := schema.Generate(tc.val) + if err != nil { + t.Fatalf("schema.Generate(%T) failed: %v", tc.val, err) + } + if s == nil { + t.Fatalf("schema.Generate(%T) returned nil schema", tc.val) + } + }) + } +} diff --git a/go.mod b/go.mod index 6cd037e..d672489 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/gorilla/websocket v1.5.3 go.klarlabs.de/bolt v1.5.2 go.klarlabs.de/fortify v1.8.1 - go.klarlabs.de/mcp v1.21.0 + go.klarlabs.de/mcp v1.22.0 go.klarlabs.de/statekit v1.8.0 ) diff --git a/go.sum b/go.sum index f3c8435..3be6df1 100644 --- a/go.sum +++ b/go.sum @@ -23,8 +23,8 @@ go.klarlabs.de/bolt v1.5.2 h1:HB5p1tCZdizz7JpTSZYFPtp6R9oJB1LLVq74M9vH88w= go.klarlabs.de/bolt v1.5.2/go.mod h1:wz+vynyuDkLK2fgmoi0VvLurqvq4JcnMTMyDQpW+f7s= go.klarlabs.de/fortify v1.8.1 h1:gyzJ7ESNe/Qf65DhGCT1WPWDyphgEDApb2QTx3Gd2m0= go.klarlabs.de/fortify v1.8.1/go.mod h1:x4Rnk8xt8ckJ8Pxe5SsXZVfGYjJIoOHx67DBl5Ncis8= -go.klarlabs.de/mcp v1.21.0 h1:SFf0Cr2rPYhLw0VnW1eec7y77vFddfEGXsPCv1ixEWs= -go.klarlabs.de/mcp v1.21.0/go.mod h1:ZeezqVm3aJFDN2+a7W0BVrLcEDhD2tdA+tQb+Klv5FE= +go.klarlabs.de/mcp v1.22.0 h1:2ufVDqJT2/+kt5zd8B9HjbxJUbz3DYEVe43PqwTQ/QI= +go.klarlabs.de/mcp v1.22.0/go.mod h1:ZeezqVm3aJFDN2+a7W0BVrLcEDhD2tdA+tQb+Klv5FE= go.klarlabs.de/statekit v1.8.0 h1:bOTAuextbB46f2tzXs9zpZ5qdwLIzE1e8OE5eDPv6fM= go.klarlabs.de/statekit v1.8.0/go.mod h1:ZYFkCAGSdRm/jx7QDvVqWsGwRZGc8ndhE7dk7NtsE7E= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=