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
29 changes: 24 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -939,22 +939,41 @@ Destinations are the OTLP/HTTP endpoints sessions export to, managed per project

### Browser WebMCP

- `kernel browsers webmcp list <id-or-name>` - 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 <id-or-name>` - 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 <id-or-name> --tool-ref <ref> --input '<json object>'` - Invoke the exact live tool registration
- `--tool-ref <ref>` - Opaque reference from `webmcp list` (required; do not reconstruct it from the tool name)
- `--input <json>` or `--input-file <path>` - Required JSON object; mutually exclusive. Use `--input-file -` to read stdin
- `--timeout-sec <seconds>` - Positive maximum execution time (defaults server-side)
- `--timeout-sec <seconds>` - 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 <id-or-name>` - 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 <id-or-name> --namespace <namespace> --source-file <path>` - Register a batch of custom tools
- `--source-file <path>` - 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 <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 <id-or-name> <tool-id>` - 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 '<tool_ref>' --input '{"query":"example"}' --timeout-sec 30
kernel browsers webmcp invoke my-browser --tool-ref '<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
Expand Down
33 changes: 20 additions & 13 deletions cmd/browsers_webmcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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 {
Expand All @@ -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, kernel.BrowserWebmcpListToolsParams{})
res, err := b.webmcp.ListTools(ctx, in.Identifier, kernel.BrowserWebmcpListToolsParams{ExcludeCustom: in.ExcludeCustom})
if err != nil {
return util.CleanedUpSdkError{Err: err}
}
Expand All @@ -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 {
Expand All @@ -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)
}
Expand All @@ -113,17 +114,18 @@ 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 <id-or-name>", 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 <id-or-name>", 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 <id-or-name>",
Short: "Invoke a WebMCP tool without automatic retries",
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),
Expand All @@ -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
}

Expand All @@ -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 {
Expand Down
203 changes: 203 additions & 0 deletions cmd/browsers_webmcp_custom_tools.go
Original file line number Diff line number Diff line change
@@ -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}, option.WithMaxRetries(0)); 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 <id-or-name>", Short: "List registered custom tools, including tools not currently matching a page", Args: cobra.ExactArgs(1), RunE: runBrowsersWebMCPCustomToolsList}
add := &cobra.Command{
Use: "add <id-or-name>",
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 <id-or-name> <tool-id>", 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])
}
Loading
Loading