From abf9a0cf47360e034f371376c9eb2d0cb8d14711 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:53:10 +0000 Subject: [PATCH 1/2] Update Go SDK and add custom WebMCP tool commands --- README.md | 29 ++- cmd/browsers_webmcp.go | 41 ++-- cmd/browsers_webmcp_custom_tools.go | 203 ++++++++++++++++++++ cmd/browsers_webmcp_custom_tools_test.go | 228 +++++++++++++++++++++++ cmd/browsers_webmcp_test.go | 16 +- go.mod | 2 +- go.sum | 4 +- 7 files changed, 491 insertions(+), 32 deletions(-) create mode 100644 cmd/browsers_webmcp_custom_tools.go create mode 100644 cmd/browsers_webmcp_custom_tools_test.go diff --git a/README.md b/README.md index 21fbba85..7c0aa488 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ Commands with JSON output support: - **Apps**: `list`, `history` - **Deploy**: `deploy` (JSONL streaming), `history` - **Invoke**: `invoke` (JSONL streaming), `history` -- **Browser Sub-commands**: `replays list/start`, `process exec/spawn`, `fs file-info/list-files`, `webmcp list` (`webmcp invoke` always prints JSON output) +- **Browser Sub-commands**: `replays list/start`, `process exec/spawn`, `fs file-info/list-files`, `webmcp list`, `webmcp custom-tools list/add` (`webmcp invoke` always prints JSON output) - **Browser NDJSON streaming**: `telemetry stream` ### Search @@ -938,22 +938,41 @@ Destinations are the OTLP/HTTP endpoints sessions export to, managed per project ### Browser WebMCP -- `kernel browsers webmcp list ` - Discover native page tools across all browser tabs and embedded frames - - Displays name, opaque tool reference, page URL, tab ID, and read-only annotation (`-` when absent) - - `--json`, `--output json`, `-o json` - Output the raw response, including descriptions, input schemas, annotations, and source details +- `kernel browsers webmcp list ` - Discover native and custom tools across all browser tabs and embedded frames + - Displays name, opaque tool reference, page URL, tab ID, and `readOnlyHint` annotation (`-` when absent) + - `--exclude-custom` - Return only page-provided tools + - `--json`, `--output json`, `-o json` - Output the raw response: each entry has `tool_ref`, nested `tool` metadata (`name`, optional `title`, `description`, `inputSchema`, optional `outputSchema`, and annotations), and `source` details. Custom registrations include `source.custom` (`id`, `namespace`) and `source.target_id` + - Annotations use `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`, `consequentialHint`, `untrustedContentHint`, and `autosubmit` - `kernel browsers webmcp invoke --tool-ref --input ''` - Invoke the exact live tool registration - `--tool-ref ` - Opaque reference from `webmcp list` (required; do not reconstruct it from the tool name) - `--input ` or `--input-file ` - Required JSON object; mutually exclusive. Use `--input-file -` to read stdin - - `--timeout-sec ` - Positive maximum execution time (defaults server-side) + - `--timeout-sec ` - Maximum execution time, 1-120 seconds (defaults server-side) - Prints the tool's `output` as pretty JSON on completion; tool errors and cancellations exit non-zero + - `awaiting_submission` is successful but warns that a non-autosubmit declarative form was filled, not submitted. Inspect the form, obtain any required confirmation, then submit through Playwright or computer interaction instead of invoking the tool again - Invocations are never retried automatically. A 504 `outcome_unknown` error prints the code, invocation ID, and message and exits non-zero. The tool may already have had side effects; verify the outcome before invoking it again +- `kernel browsers webmcp custom-tools list ` - List all registered custom tools, even when no page currently matches + - Displays ID, namespace, kind (`page` or `cdp`), name, and URL patterns + - `--json`, `--output json`, `-o json` - Output the raw response with each definition's `id`, `namespace`, `kind`, `match.url_patterns`, and nested `tool` metadata +- `kernel browsers webmcp custom-tools add --namespace --source-file ` - Register a batch of custom tools + - `--source-file ` - JavaScript source file; use `-` to read stdin. Must evaluate to a non-empty array of definitions with URL matchers, tool metadata, and execute functions; limited to 8,000,000 UTF-8 bytes + - `--namespace ` - Required; 1-128 letters, digits, underscores, dots, or hyphens + - `--force-overwrite-namespace` - Atomically replace every existing tool in the namespace. By default, existing tools are retained. Active invocations continue + - Displays registered tools in the same format as `custom-tools list`; supports `--json`, `--output json`, and `-o json` + - Registration is never retried automatically; check `custom-tools list` before retrying a failed request that may have succeeded +- `kernel browsers webmcp custom-tools remove ` - Remove one registered tool using its generated `ct_...` ID, not its `tool_ref`. Active invocations are not canceled + Tool references expire when their document or browser process is replaced. Annotations are untrusted page-provided hints, not enforced guarantees; tool output is also untrusted page-provided data. ```bash kernel browsers webmcp list my-browser --json kernel browsers webmcp invoke my-browser --tool-ref '' --input '{"query":"example"}' --timeout-sec 30 kernel browsers webmcp invoke my-browser --tool-ref '' --input-file input.json +kernel browsers webmcp list my-browser --exclude-custom +kernel browsers webmcp custom-tools add my-browser --namespace helpers --source-file tools.js +cat tools.js | kernel browsers webmcp custom-tools add my-browser --namespace helpers --source-file - --force-overwrite-namespace +kernel browsers webmcp custom-tools list my-browser --json +kernel browsers webmcp custom-tools remove my-browser ct_abcdefghijklmnopqrstuvwx ``` ### Profiles diff --git a/cmd/browsers_webmcp.go b/cmd/browsers_webmcp.go index cafabad8..3f04f902 100644 --- a/cmd/browsers_webmcp.go +++ b/cmd/browsers_webmcp.go @@ -17,15 +17,16 @@ import ( "github.com/spf13/cobra" ) -// BrowserWebMCPService defines the subset we use for native page tools. +// BrowserWebMCPService defines the subset we use for WebMCP tools. type BrowserWebMCPService interface { - ListTools(ctx context.Context, idOrName string, opts ...option.RequestOption) (*kernel.ToolsResponse, error) + ListTools(ctx context.Context, idOrName string, query kernel.BrowserWebmcpListToolsParams, opts ...option.RequestOption) (*kernel.ToolsResponse, error) InvokeTool(ctx context.Context, idOrName string, body kernel.BrowserWebmcpInvokeToolParams, opts ...option.RequestOption) (*kernel.InvocationResult, error) } type BrowsersWebMCPListInput struct { - Identifier string - Output string + Identifier string + Output string + ExcludeCustom param.Opt[bool] } type BrowsersWebMCPInvokeInput struct { @@ -39,7 +40,7 @@ func (b BrowsersCmd) WebMCPList(ctx context.Context, in BrowsersWebMCPListInput) if err := validateJSONOutput(in.Output); err != nil { return err } - res, err := b.webmcp.ListTools(ctx, in.Identifier) + res, err := b.webmcp.ListTools(ctx, in.Identifier, kernel.BrowserWebmcpListToolsParams{ExcludeCustom: in.ExcludeCustom}) if err != nil { return util.CleanedUpSdkError{Err: err} } @@ -53,10 +54,10 @@ func (b BrowsersCmd) WebMCPList(ctx context.Context, in BrowsersWebMCPListInput) rows := pterm.TableData{{"Name", "Tool Ref", "Page URL", "Tab ID", "Read Only"}} for _, tool := range res.Tools { readOnly := "-" - if tool.Annotations.JSON.ReadOnly.Valid() { - readOnly = strconv.FormatBool(tool.Annotations.ReadOnly) + if tool.Tool.Annotations.JSON.ReadOnlyHint.Valid() { + readOnly = strconv.FormatBool(tool.Tool.Annotations.ReadOnlyHint) } - rows = append(rows, []string{tool.Name, tool.ToolRef, tool.Source.PageURL, strconv.FormatInt(tool.Source.TabID, 10), readOnly}) + rows = append(rows, []string{tool.Tool.Name, tool.ToolRef, tool.Source.PageURL, strconv.FormatInt(tool.Source.TabID, 10), readOnly}) } PrintTableNoPad(rows, true) return nil @@ -66,8 +67,8 @@ func (b BrowsersCmd) WebMCPInvoke(ctx context.Context, in BrowsersWebMCPInvokeIn if strings.TrimSpace(in.ToolRef) == "" { return fmt.Errorf("missing --tool-ref value") } - if in.TimeoutSec.Valid() && in.TimeoutSec.Value <= 0 { - return fmt.Errorf("invalid --timeout-sec value: must be greater than zero") + if in.TimeoutSec.Valid() && (in.TimeoutSec.Value < 1 || in.TimeoutSec.Value > 120) { + return fmt.Errorf("invalid --timeout-sec value: must be between 1 and 120") } var fields map[string]json.RawMessage if err := json.Unmarshal([]byte(in.Input), &fields); err != nil { @@ -93,7 +94,7 @@ func (b BrowsersCmd) WebMCPInvoke(ctx context.Context, in BrowsersWebMCPInvokeIn case kernel.InvocationResultStatusAwaitingSubmission: // A populated form is a result, not a failure. Re-invoking would refill the // same fields, so point the caller at submitting the form they already have. - pterm.Warning.Printfln("WebMCP invocation %s populated a form without submitting it. Inspect the form and obtain any required confirmation, then submit it with 'kernel browsers playwright execute' or 'kernel browsers computer' rather than invoking the tool again.", res.InvocationID) + pterm.Warning.Printfln("WebMCP invocation %s: awaiting_submission — populated a form without submitting it. Inspect the form and obtain any required confirmation, then submit it with 'kernel browsers playwright execute' or 'kernel browsers computer' rather than invoking the tool again.", res.InvocationID) default: return fmt.Errorf("WebMCP invocation %s: %s: %s", res.InvocationID, res.Status, res.ErrorText) } @@ -113,9 +114,10 @@ func (b BrowsersCmd) WebMCPInvoke(ctx context.Context, in BrowsersWebMCPInvokeIn } func newBrowsersWebMCPCommand() *cobra.Command { - root := &cobra.Command{Use: "webmcp", Short: "Discover and invoke native page tools"} - list := &cobra.Command{Use: "list ", Short: "List WebMCP tools across browser tabs and frames", Args: cobra.ExactArgs(1), RunE: runBrowsersWebMCPList} + root := &cobra.Command{Use: "webmcp", Short: "Discover, invoke, and manage native and custom WebMCP tools"} + list := &cobra.Command{Use: "list ", Short: "List native and custom WebMCP tools across browser tabs and frames", Args: cobra.ExactArgs(1), RunE: runBrowsersWebMCPList} addJSONOutputFlag(list) + list.Flags().Bool("exclude-custom", false, "List only page-provided tools, excluding custom tools") list.Flags().Bool("json", false, "Output the raw API response as JSON (alias for --output json)") invoke := &cobra.Command{ Use: "invoke ", @@ -123,7 +125,7 @@ func newBrowsersWebMCPCommand() *cobra.Command { Long: "Invoke a WebMCP tool without automatic retries.\n\n" + "Most tools wait for a terminal result, including across navigation. A tool " + "backed by a declarative form that does not autosubmit instead returns once " + - "its fields are populated: inspect the form, obtain any required confirmation, " + + "its fields are populated (awaiting_submission): inspect the form, obtain any required confirmation, " + "then submit it through Playwright or computer interaction without invoking " + "the tool again.", Args: cobra.ExactArgs(1), @@ -135,8 +137,8 @@ func newBrowsersWebMCPCommand() *cobra.Command { invoke.Flags().String("input-file", "", "Path to a JSON object file (use '-' for stdin)") invoke.MarkFlagsOneRequired("input", "input-file") invoke.MarkFlagsMutuallyExclusive("input", "input-file") - invoke.Flags().Int64("timeout-sec", 0, "Maximum execution time in seconds (default per server)") - root.AddCommand(list, invoke) + invoke.Flags().Int64("timeout-sec", 0, "Maximum execution time in seconds, 1-120 (default per server)") + root.AddCommand(list, invoke, newBrowsersWebMCPCustomToolsCommand()) return root } @@ -151,7 +153,12 @@ func runBrowsersWebMCPList(cmd *cobra.Command, args []string) error { } client := getKernelClient(cmd) b := BrowsersCmd{webmcp: &client.Browsers.Webmcp} - return b.WebMCPList(cmd.Context(), BrowsersWebMCPListInput{Identifier: args[0], Output: output}) + var excludeCustom param.Opt[bool] + if cmd.Flags().Changed("exclude-custom") { + value, _ := cmd.Flags().GetBool("exclude-custom") + excludeCustom = kernel.Opt(value) + } + return b.WebMCPList(cmd.Context(), BrowsersWebMCPListInput{Identifier: args[0], Output: output, ExcludeCustom: excludeCustom}) } func runBrowsersWebMCPInvoke(cmd *cobra.Command, args []string) error { diff --git a/cmd/browsers_webmcp_custom_tools.go b/cmd/browsers_webmcp_custom_tools.go new file mode 100644 index 00000000..89df4669 --- /dev/null +++ b/cmd/browsers_webmcp_custom_tools.go @@ -0,0 +1,203 @@ +package cmd + +import ( + "context" + "fmt" + "io" + "os" + "regexp" + "strings" + "unicode/utf8" + + "github.com/kernel/cli/pkg/util" + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk/packages/param" + "github.com/pterm/pterm" + "github.com/spf13/cobra" +) + +const webMCPCustomSourceMaxBytes = 8_000_000 + +var webMCPCustomNamespacePattern = regexp.MustCompile(`^[A-Za-z0-9_.-]{1,128}$`) +var webMCPCustomIDPattern = regexp.MustCompile(`^ct_[a-z][a-z0-9]{23}$`) + +type BrowserWebMCPCustomToolsService interface { + List(ctx context.Context, idOrName string, opts ...option.RequestOption) (*kernel.CustomToolsResponse, error) + Add(ctx context.Context, idOrName string, body kernel.BrowserWebmcpCustomToolAddParams, opts ...option.RequestOption) (*kernel.CustomToolsResponse, error) + Remove(ctx context.Context, id string, body kernel.BrowserWebmcpCustomToolRemoveParams, opts ...option.RequestOption) error +} + +type BrowsersWebMCPCustomToolsCmd struct { + tools BrowserWebMCPCustomToolsService +} + +type BrowsersWebMCPCustomToolsListInput struct { + Identifier string + Output string +} + +type BrowsersWebMCPCustomToolsAddInput struct { + Identifier string + Namespace string + Source string + ForceOverwriteNamespace param.Opt[bool] + Output string +} + +func (b BrowsersWebMCPCustomToolsCmd) List(ctx context.Context, in BrowsersWebMCPCustomToolsListInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } + res, err := b.tools.List(ctx, in.Identifier) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + return printWebMCPCustomTools(res, in.Output) +} + +func (b BrowsersWebMCPCustomToolsCmd) Add(ctx context.Context, in BrowsersWebMCPCustomToolsAddInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } + if !webMCPCustomNamespacePattern.MatchString(in.Namespace) { + return fmt.Errorf("invalid --namespace: use 1-128 letters, digits, underscores, dots, or hyphens") + } + if strings.TrimSpace(in.Source) == "" { + return fmt.Errorf("custom tool source must not be empty") + } + if len(in.Source) > webMCPCustomSourceMaxBytes { + return fmt.Errorf("custom tool source exceeds %d UTF-8 bytes", webMCPCustomSourceMaxBytes) + } + if !utf8.ValidString(in.Source) { + return fmt.Errorf("custom tool source must be valid UTF-8") + } + // Retrying a lost response could register the same batch twice. + res, err := b.tools.Add(ctx, in.Identifier, kernel.BrowserWebmcpCustomToolAddParams{ + AddRequest: kernel.AddRequestParam{ + Namespace: in.Namespace, Source: in.Source, ForceOverwriteNamespace: in.ForceOverwriteNamespace, + }, + }, option.WithMaxRetries(0)) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + return printWebMCPCustomTools(res, in.Output) +} + +func (b BrowsersWebMCPCustomToolsCmd) Remove(ctx context.Context, identifier, id string) error { + if !webMCPCustomIDPattern.MatchString(id) { + return fmt.Errorf("invalid custom tool ID: expected ct_ followed by a lowercase letter and 23 lowercase letters or digits") + } + if err := b.tools.Remove(ctx, id, kernel.BrowserWebmcpCustomToolRemoveParams{IDOrName: identifier}); err != nil { + return util.CleanedUpSdkError{Err: err} + } + pterm.Success.Printf("Removed custom WebMCP tool: %s\n", id) + return nil +} + +func printWebMCPCustomTools(res *kernel.CustomToolsResponse, output string) error { + if output == "json" { + return util.PrintPrettyJSON(res) + } + if len(res.Tools) == 0 { + pterm.Info.Println("No custom WebMCP tools found") + return nil + } + rows := pterm.TableData{{"ID", "Namespace", "Kind", "Name", "URL Patterns"}} + for _, tool := range res.Tools { + rows = append(rows, []string{tool.ID, tool.Namespace, tool.Kind, tool.Tool.Name, strings.Join(tool.Match.URLPatterns, ", ")}) + } + PrintTableNoPad(rows, true) + return nil +} + +func newBrowsersWebMCPCustomToolsCommand() *cobra.Command { + root := &cobra.Command{Use: "custom-tools", Short: "Manage registered custom WebMCP tools"} + list := &cobra.Command{Use: "list ", Short: "List registered custom tools, including tools not currently matching a page", Args: cobra.ExactArgs(1), RunE: runBrowsersWebMCPCustomToolsList} + add := &cobra.Command{ + Use: "add ", + Short: "Register custom tools from a JavaScript source file or stdin", + Long: "Register a namespaced batch of page-backed or CDP-backed custom tools.\n\n" + + "Source must be a JavaScript expression evaluating to a non-empty array of tool " + + "definitions with URL matchers, tool metadata, and execute functions (at most 8,000,000 UTF-8 bytes). " + + "By default, existing tools are retained. --force-overwrite-namespace atomically replaces " + + "all tools in that namespace; existing invocations continue. Registration is not automatically retried.", + Args: cobra.ExactArgs(1), + RunE: runBrowsersWebMCPCustomToolsAdd, + } + add.Flags().String("namespace", "", "Tool namespace (1-128 letters, digits, underscores, dots, or hyphens)") + add.Flags().String("source-file", "", "Path to JavaScript source (use '-' for stdin)") + add.Flags().Bool("force-overwrite-namespace", false, "Atomically replace all existing tools in this namespace") + _ = add.MarkFlagRequired("namespace") + _ = add.MarkFlagRequired("source-file") + for _, cmd := range []*cobra.Command{list, add} { + addJSONOutputFlag(cmd) + cmd.Flags().Bool("json", false, "Output the raw API response as JSON (alias for --output json)") + } + remove := &cobra.Command{ + Use: "remove ", Short: "Remove a registered custom tool by ID without canceling active invocations", + Args: cobra.ExactArgs(2), RunE: runBrowsersWebMCPCustomToolsRemove, + } + root.AddCommand(list, add, remove) + return root +} + +func webMCPCustomToolsOutput(cmd *cobra.Command) (string, error) { + output, _ := cmd.Flags().GetString("output") + if err := validateJSONOutput(output); err != nil { + return "", err + } + asJSON, _ := cmd.Flags().GetBool("json") + if asJSON { + output = "json" + } + return output, nil +} + +func runBrowsersWebMCPCustomToolsList(cmd *cobra.Command, args []string) error { + output, err := webMCPCustomToolsOutput(cmd) + if err != nil { + return err + } + client := getKernelClient(cmd) + b := BrowsersWebMCPCustomToolsCmd{tools: &client.Browsers.Webmcp.CustomTools} + return b.List(cmd.Context(), BrowsersWebMCPCustomToolsListInput{Identifier: args[0], Output: output}) +} + +func runBrowsersWebMCPCustomToolsAdd(cmd *cobra.Command, args []string) error { + output, err := webMCPCustomToolsOutput(cmd) + if err != nil { + return err + } + path, _ := cmd.Flags().GetString("source-file") + reader := cmd.InOrStdin() + if path != "-" { + file, err := os.Open(path) + if err != nil { + return fmt.Errorf("failed to read source file: %w", err) + } + defer func() { _ = file.Close() }() + reader = file + } + data, err := io.ReadAll(io.LimitReader(reader, webMCPCustomSourceMaxBytes+1)) + if err != nil { + return fmt.Errorf("failed to read source file: %w", err) + } + namespace, _ := cmd.Flags().GetString("namespace") + var force param.Opt[bool] + if cmd.Flags().Changed("force-overwrite-namespace") { + value, _ := cmd.Flags().GetBool("force-overwrite-namespace") + force = kernel.Opt(value) + } + client := getKernelClient(cmd) + b := BrowsersWebMCPCustomToolsCmd{tools: &client.Browsers.Webmcp.CustomTools} + return b.Add(cmd.Context(), BrowsersWebMCPCustomToolsAddInput{ + Identifier: args[0], Namespace: namespace, Source: string(data), ForceOverwriteNamespace: force, Output: output, + }) +} + +func runBrowsersWebMCPCustomToolsRemove(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + b := BrowsersWebMCPCustomToolsCmd{tools: &client.Browsers.Webmcp.CustomTools} + return b.Remove(cmd.Context(), args[0], args[1]) +} diff --git a/cmd/browsers_webmcp_custom_tools_test.go b/cmd/browsers_webmcp_custom_tools_test.go new file mode 100644 index 00000000..1301b523 --- /dev/null +++ b/cmd/browsers_webmcp_custom_tools_test.go @@ -0,0 +1,228 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const webMCPCustomToolsFixture = `{"tools":[{"id":"ct_abcdefghijklmnopqrstuvwx","namespace":"helpers","kind":"page","match":{"url_patterns":["https://example.com/*","https://example.org/*"]},"tool":{"name":"search","title":"Search","description":"Search the page","inputSchema":{"type":"object"},"outputSchema":{"type":"object"},"annotations":{"readOnlyHint":true}}},{"id":"ct_bcdefghijklmnopqrstuvwxy","namespace":"helpers","kind":"cdp","match":{"url_patterns":["https://example.net/*"]},"tool":{"name":"inspect","description":"Inspect the page","inputSchema":{"type":"object"}}}],"future_field":true}` + +func TestWebMCPListExcludeCustom(t *testing.T) { + for _, tc := range []struct { + flag, query string + }{ + {"", ""}, + {"--exclude-custom", "exclude_custom=true"}, + {"--exclude-custom=false", "exclude_custom=false"}, + } { + t.Run(tc.flag, func(t *testing.T) { + args := []string{"list", "browser"} + if tc.flag != "" { + args = append(args, tc.flag) + } + _, _, err := executeWebMCPCommand(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, tc.query, r.URL.RawQuery) + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `{"tools":[]}`) + }, "", args...) + require.NoError(t, err) + }) + } +} + +func TestWebMCPCustomToolsList(t *testing.T) { + for _, fixture := range []string{webMCPCustomToolsFixture, `{"tools":[]}`} { + for _, flags := range [][]string{nil, {"--json"}, {"-o", "json"}, {"--output", "json"}} { + t.Run(fixture+strings.Join(flags, " "), func(t *testing.T) { + calls := 0 + stdout, table, err := executeWebMCPCommand(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/browsers/my-browser/webmcp/custom-tools", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, fixture) + }, "", append([]string{"custom-tools", "list", "my-browser"}, flags...)...) + require.NoError(t, err) + assert.Equal(t, 1, calls) + if len(flags) > 0 { + assert.JSONEq(t, fixture, stdout) + assert.Empty(t, table) + } else if fixture == `{"tools":[]}` { + assert.Contains(t, table, "No custom WebMCP tools found") + } else { + for _, value := range []string{"ID", "Namespace", "Kind", "Name", "URL Patterns", "ct_abcdefghijklmnopqrstuvwx", "helpers", "page", "cdp", "search", "inspect", "https://example.com/*, https://example.org/*"} { + assert.Contains(t, table, value) + } + } + }) + } + } +} + +func TestWebMCPCustomToolsAdd(t *testing.T) { + source := `[{kind: "page", match: {url_patterns: ["https://example.com/*"]}, tool: {name: "search", description: "café", inputSchema: {type: "object"}}, execute: async () => ({ok: true})}]` + file := filepath.Join(t.TempDir(), "tools.js") + require.NoError(t, os.WriteFile(file, []byte(source), 0600)) + forceTrue, forceFalse := true, false + for _, tc := range []struct { + name, path, stdin string + flags []string + force *bool + }{ + {name: "file", path: file}, + {name: "stdin", path: "-", stdin: source, flags: []string{"--json"}}, + {name: "overwrite", path: file, flags: []string{"--force-overwrite-namespace", "-o", "json"}, force: &forceTrue}, + {name: "explicit false", path: file, flags: []string{"--force-overwrite-namespace=false", "--output", "json"}, force: &forceFalse}, + } { + t.Run(tc.name, func(t *testing.T) { + calls := 0 + stdout, table, err := executeWebMCPCommand(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/browsers/my-browser/webmcp/custom-tools", r.URL.Path) + var body struct { + Namespace string `json:"namespace"` + Source string `json:"source"` + Force *bool `json:"force_overwrite_namespace"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "helpers", body.Namespace) + assert.Equal(t, source, body.Source) + assert.Equal(t, tc.force, body.Force) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = fmt.Fprint(w, webMCPCustomToolsFixture) + }, tc.stdin, append([]string{"custom-tools", "add", "my-browser", "--namespace", "helpers", "--source-file", tc.path}, tc.flags...)...) + require.NoError(t, err) + assert.Equal(t, 1, calls) + if len(tc.flags) > 0 { + assert.JSONEq(t, webMCPCustomToolsFixture, stdout) + assert.Empty(t, table) + } else { + assert.Contains(t, table, "ct_abcdefghijklmnopqrstuvwx") + assert.Contains(t, table, "search") + } + }) + } +} + +func TestWebMCPCustomToolsRemove(t *testing.T) { + calls := 0 + stdout, output, err := executeWebMCPCommand(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, "/browsers/my-browser/webmcp/custom-tools/ct_abcdefghijklmnopqrstuvwx", r.URL.Path) + w.WriteHeader(http.StatusNoContent) + }, "", "custom-tools", "remove", "my-browser", "ct_abcdefghijklmnopqrstuvwx") + require.NoError(t, err) + assert.Equal(t, 1, calls) + assert.Empty(t, stdout) + assert.Contains(t, output, "Removed custom WebMCP tool: ct_abcdefghijklmnopqrstuvwx") +} + +func TestWebMCPCustomToolsInvalidInput(t *testing.T) { + for _, tc := range []struct { + args []string + want string + }{ + {[]string{"list"}, "accepts 1 arg"}, + {[]string{"list", "browser", "extra"}, "accepts 1 arg"}, + {[]string{"list", "browser", "--json", "-o", "yaml"}, "unsupported --output"}, + {[]string{"add", "browser"}, "required flag"}, + {[]string{"add", "browser", "--namespace", "helpers"}, "required flag"}, + {[]string{"add", "browser", "--source-file", "-"}, "required flag"}, + {[]string{"add", "browser", "--namespace", "helpers", "--source-file", "-", "--json", "-o", "yaml"}, "unsupported --output"}, + {[]string{"add", "browser", "--namespace", "helpers", "--source-file", filepath.Join(t.TempDir(), "missing")}, "failed to read source file"}, + {[]string{"remove", "browser"}, "accepts 2 arg"}, + {[]string{"remove", "browser", "not-an-id"}, "invalid custom tool ID"}, + } { + t.Run(strings.Join(tc.args, " "), func(t *testing.T) { + _, _, err := executeWebMCPCommand(t, func(w http.ResponseWriter, r *http.Request) { + t.Error("invalid input reached API") + }, "", append([]string{"custom-tools"}, tc.args...)...) + require.ErrorContains(t, err, tc.want) + }) + } +} + +func TestWebMCPCustomToolsSourceValidation(t *testing.T) { + for _, tc := range []struct{ name, namespace, source, want string }{ + {"empty namespace", "", "[]", "invalid --namespace"}, + {"invalid namespace", "bad/name", "[]", "invalid --namespace"}, + {"long namespace", strings.Repeat("a", 129), "[]", "invalid --namespace"}, + {"empty source", "helpers", "", "must not be empty"}, + {"blank source", "helpers", " \n\t", "must not be empty"}, + {"oversized", "helpers", strings.Repeat("é", webMCPCustomSourceMaxBytes/2+1), "exceeds 8000000 UTF-8 bytes"}, + {"invalid UTF-8", "helpers", string([]byte{0xff}), "must be valid UTF-8"}, + } { + t.Run(tc.name, func(t *testing.T) { + _, _, err := executeWebMCPCommand(t, func(w http.ResponseWriter, r *http.Request) { + t.Error("invalid input reached API") + }, tc.source, "custom-tools", "add", "browser", "--namespace", tc.namespace, "--source-file", "-") + require.ErrorContains(t, err, tc.want) + }) + } +} + +func TestWebMCPCustomToolsSourceLimit(t *testing.T) { + source := "/*" + strings.Repeat("é", (webMCPCustomSourceMaxBytes-6)/2) + "*/[]" + namespace := "A_-." + strings.Repeat("a", 124) + calls := 0 + _, _, err := executeWebMCPCommand(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + var body struct { + Source string `json:"source"` + Namespace string `json:"namespace"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, source, body.Source) + assert.Len(t, body.Source, webMCPCustomSourceMaxBytes) + assert.Equal(t, namespace, body.Namespace) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = fmt.Fprint(w, webMCPCustomToolsFixture) + }, source, "custom-tools", "add", "browser", "--namespace", namespace, "--source-file", "-") + require.NoError(t, err) + assert.Equal(t, 1, calls) +} + +func TestWebMCPCustomToolsAPIErrors(t *testing.T) { + for _, args := range [][]string{ + {"list", "browser"}, + {"add", "browser", "--namespace", "helpers", "--source-file", "-"}, + {"remove", "browser", "ct_abcdefghijklmnopqrstuvwx"}, + } { + t.Run(args[0], func(t *testing.T) { + stdout, output, err := executeWebMCPCommand(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = fmt.Fprint(w, `{"code":"invalid_request","message":"Invalid custom tool"}`) + }, "[]", append([]string{"custom-tools"}, args...)...) + require.EqualError(t, err, "invalid_request: Invalid custom tool") + assert.Empty(t, stdout) + assert.Empty(t, output) + }) + } +} + +func TestWebMCPCustomToolsAddNeverRetries(t *testing.T) { + calls := 0 + _, _, err := executeWebMCPCommand(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Should-Retry", "true") + w.Header().Set("Retry-After", "0") + w.WriteHeader(http.StatusInternalServerError) + _, _ = fmt.Fprint(w, `{"message":"Response lost"}`) + }, "[]", "custom-tools", "add", "browser", "--namespace", "helpers", "--source-file", "-") + require.Error(t, err) + assert.Equal(t, 1, calls) +} diff --git a/cmd/browsers_webmcp_test.go b/cmd/browsers_webmcp_test.go index 179e7d4b..4aa6eaec 100644 --- a/cmd/browsers_webmcp_test.go +++ b/cmd/browsers_webmcp_test.go @@ -36,11 +36,12 @@ func executeWebMCPCommand(t *testing.T, handler http.HandlerFunc, stdin string, return stdout, buf.String(), err } -const webMCPToolsFixture = `{"tools":[{"name":"search","tool_ref":"opaque/ref+==","description":"Search the page","input_schema":{"type":"object"},"annotations":{"read_only":true,"autosubmit":false,"consequential":false,"untrusted_content":true},"source":{"window_id":1,"tab_id":42,"page_url":"https://example.com","page_title":"Example","frame":null}}],"future_field":true}` +const webMCPToolsFixture = `{"tools":[{"tool_ref":"opaque/ref+==","tool":{"name":"search","title":"Search","description":"Search the page","inputSchema":{"type":"object"},"outputSchema":{"type":"object"},"annotations":{"readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false,"autosubmit":false,"consequentialHint":false,"untrustedContentHint":true}},"source":{"window_id":1,"tab_id":42,"page_url":"https://example.com","page_title":"Example","frame":null,"target_id":"target-1","custom":{"id":"ct_abcdefghijklmnopqrstuvwx","namespace":"helpers"}}}],"future_field":true}` func TestWebMCPCommandWiring(t *testing.T) { - for _, name := range []string{"list", "invoke"} { - cmd, remaining, err := rootCmd.Find([]string{"browsers", "webmcp", name}) + for _, path := range [][]string{{"list"}, {"invoke"}, {"custom-tools", "list"}, {"custom-tools", "add"}, {"custom-tools", "remove"}} { + name := path[len(path)-1] + cmd, remaining, err := rootCmd.Find(append([]string{"browsers", "webmcp"}, path...)) require.NoError(t, err) require.Empty(t, remaining) assert.Equal(t, name, cmd.Name()) @@ -93,15 +94,15 @@ func TestWebMCPListEmpty(t *testing.T) { func TestWebMCPListAnnotations(t *testing.T) { for _, tc := range []struct{ annotation, want string }{ - {`{"read_only":true}`, "true"}, - {`{"read_only":false}`, "false"}, + {`{"readOnlyHint":true}`, "true"}, + {`{"readOnlyHint":false}`, "false"}, {`{}`, "-"}, {`null`, "-"}, } { t.Run(tc.annotation, func(t *testing.T) { _, table, err := executeWebMCPCommand(t, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - fmt.Fprintf(w, `{"tools":[{"name":"search","annotations":%s}]}`, tc.annotation) + _, _ = fmt.Fprintf(w, `{"tools":[{"tool":{"name":"search","annotations":%s}}]}`, tc.annotation) }, "", "list", "my-browser") require.NoError(t, err) rows := strings.Split(strings.TrimSpace(table), "\n") @@ -192,7 +193,7 @@ func TestWebMCPInvalidInput(t *testing.T) { want string }{[]string{"invoke", "browser", "--tool-ref", "ref", "--input", input}, "invalid input"}) } - for _, timeout := range []string{"0", "-1"} { + for _, timeout := range []string{"0", "-1", "121"} { tests = append(tests, struct { args []string want string @@ -252,6 +253,7 @@ func TestWebMCPInvokeAwaitingSubmission(t *testing.T) { require.NoError(t, err) assert.JSONEq(t, `{"filled":["email"]}`, stdout) assert.Contains(t, warning, "inv-1") + assert.Contains(t, warning, "awaiting_submission") assert.Contains(t, warning, "without submitting it") } diff --git a/go.mod b/go.mod index dcae6b62..ca699418 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 - github.com/kernel/kernel-go-sdk v0.110.0 + github.com/kernel/kernel-go-sdk v0.112.0 github.com/klauspost/compress v1.18.5 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 diff --git a/go.sum b/go.sum index 1f47cec9..31ac173c 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +64,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.110.0 h1:2KkE0hAlJav5xg2818Eg+mIK2p1F2nDZ0rZdA2EO1QQ= -github.com/kernel/kernel-go-sdk v0.110.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.112.0 h1:WCWHRtHQs/z4Q8cwZ/uS/HnxZZi6roge+VYai5d73ic= +github.com/kernel/kernel-go-sdk v0.112.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= From 64f5f3b7c51766ca54537f87d79e69e684ab7dc0 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:42:44 +0000 Subject: [PATCH 2/2] Disable retries when removing custom WebMCP tools --- cmd/browsers_webmcp_custom_tools.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/browsers_webmcp_custom_tools.go b/cmd/browsers_webmcp_custom_tools.go index 89df4669..3f704641 100644 --- a/cmd/browsers_webmcp_custom_tools.go +++ b/cmd/browsers_webmcp_custom_tools.go @@ -88,7 +88,7 @@ func (b BrowsersWebMCPCustomToolsCmd) Remove(ctx context.Context, identifier, id if !webMCPCustomIDPattern.MatchString(id) { return fmt.Errorf("invalid custom tool ID: expected ct_ followed by a lowercase letter and 23 lowercase letters or digits") } - if err := b.tools.Remove(ctx, id, kernel.BrowserWebmcpCustomToolRemoveParams{IDOrName: identifier}); err != nil { + if err := b.tools.Remove(ctx, id, kernel.BrowserWebmcpCustomToolRemoveParams{IDOrName: identifier}, option.WithMaxRetries(0)); err != nil { return util.CleanedUpSdkError{Err: err} } pterm.Success.Printf("Removed custom WebMCP tool: %s\n", id)