Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
79ad6e8
Add REPL-backed custom WebMCP tools
rgarcia Sep 21, 2026
c02f799
Fix custom WebMCP review findings
rgarcia Sep 21, 2026
1889d4b
Reduce custom WebMCP reconciliation churn
rgarcia Sep 21, 2026
c5e2a08
Harden custom WebMCP cleanup recovery
rgarcia Sep 21, 2026
ec92c21
Recover custom WebMCP worlds after install errors
rgarcia Sep 21, 2026
c327ff7
Refine custom WebMCP tool registration
rgarcia Sep 22, 2026
5f28c8d
Publish custom tool metadata during registration
rgarcia Sep 22, 2026
cd57150
Keep custom discovery metadata current
rgarcia Sep 22, 2026
fa05d84
Guard custom tool metadata generations
rgarcia Sep 22, 2026
aadc0d4
Clarify custom WebMCP API documentation
rgarcia Sep 22, 2026
2b0c967
Simplify custom WebMCP operations and namespace updates
rgarcia Sep 23, 2026
b0e962f
Merge remote-tracking branch 'origin/main' into hypeship/custom-webmc…
rgarcia Sep 23, 2026
ce3ee31
Wrap WebMCP discovery description
rgarcia Sep 23, 2026
01c1c47
Preserve custom CDP tool results across navigation
rgarcia Sep 23, 2026
09bf83d
Classify pre-dispatch failures and bound REPL tool deadlines
rgarcia Sep 23, 2026
b93a092
Recover custom tool registrations and simplify state ownership
rgarcia Sep 23, 2026
26f56c9
Publish custom discovery generation after REPL readiness
rgarcia Sep 23, 2026
19deb60
Merge main into custom WebMCP tools
rgarcia Sep 23, 2026
5e28c7b
Preserve custom tools across invocation timeouts
rgarcia Sep 23, 2026
7f7f6d2
Honor the original custom tool invocation deadline
rgarcia Sep 23, 2026
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
1 change: 1 addition & 0 deletions server/cmd/api/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ var _ S2Storage = (*events.S2StorageController)(nil)

type webMCPClient interface {
Tools(ctx context.Context) ([]webmcpclient.Tool, error)
CustomTool(ctx context.Context, toolRef string) (id, targetID string, err error)
Invoke(ctx context.Context, toolRef string, input map[string]any) (webmcpclient.InvocationResult, error)
Close() error
}
Expand Down
89 changes: 74 additions & 15 deletions server/cmd/api/api/browser_repl.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"os"
"os/exec"
"strings"
"sync"
"time"

"github.com/google/uuid"
Expand Down Expand Up @@ -74,10 +75,12 @@ type browserReplChild struct {
// browserReplManager owns execution admission and the persistent Node child.
// Lifecycle synchronization stays behind its Execute and Shutdown methods.
type browserReplManager struct {
admission chan struct{}
lifecycle context.Context
stop context.CancelCauseFunc
child *browserReplChild // guarded by admission
admission chan struct{}
lifecycle context.Context
stop context.CancelCauseFunc
child *browserReplChild // guarded by admission
customToolsMu sync.RWMutex
customToolsReplID string
}

func newBrowserReplManager() *browserReplManager {
Expand All @@ -91,6 +94,48 @@ func newBrowserReplManager() *browserReplManager {
}
}

func (m *browserReplManager) setCustomToolsReplID(replID string) {
m.customToolsMu.Lock()
defer m.customToolsMu.Unlock()
m.customToolsReplID = replID
}

// The daemon's atomically published file is the sole discovery snapshot. A
// missing or unreadable snapshot is an error, not permission to serve stale metadata.
func (m *browserReplManager) customToolsSnapshot() (map[string]oapi.CustomWebMCPDefinition, error) {
m.customToolsMu.RLock()
defer m.customToolsMu.RUnlock()
if m.customToolsReplID == "" {
return make(map[string]oapi.CustomWebMCPDefinition), nil
}
data, err := os.ReadFile(browserReplCustomToolsPath())
if err != nil {
return nil, fmt.Errorf("read custom WebMCP discovery snapshot: %w", err)
}
var state struct {
ReplID string `json:"repl_id"`
Tools []oapi.CustomWebMCPDefinition `json:"tools"`
}
if err := json.Unmarshal(data, &state); err != nil {
return nil, fmt.Errorf("decode custom WebMCP discovery snapshot: %w", err)
}
if state.ReplID != m.customToolsReplID {
return nil, fmt.Errorf("custom WebMCP discovery snapshot belongs to another REPL")
}
if state.Tools == nil {
return nil, fmt.Errorf("custom WebMCP discovery snapshot has no tools list")
}
tools := make(map[string]oapi.CustomWebMCPDefinition, len(state.Tools))
for _, tool := range state.Tools {
tools[tool.Id] = tool
}
return tools, nil
}

func browserReplCustomToolsPath() string {
return browserReplSocketPath() + ".custom-tools.json"
}

// browserReplSocketPath returns the Unix socket path for the REPL daemon.
// Overridable for tests.
func browserReplSocketPath() string {
Expand Down Expand Up @@ -206,6 +251,8 @@ func closedWaitChannel(err error) chan error {
func (m *browserReplManager) clearLocked(ctx context.Context, child *browserReplChild) {
if m.child == child {
m.child = nil
m.setCustomToolsReplID("")
_ = os.Remove(browserReplCustomToolsPath())
}
removeBrowserReplSocket(logger.FromContext(ctx), browserReplSocketPath())
}
Expand Down Expand Up @@ -253,6 +300,7 @@ func (m *browserReplManager) startLocked(ctx context.Context) error {
conn, err := net.DialTimeout("unix", socketPath, 200*time.Millisecond)
if err == nil {
conn.Close()
m.setCustomToolsReplID(replID)
log.Info("browser REPL ready", "repl_id", replID)
return nil
}
Expand Down Expand Up @@ -359,6 +407,7 @@ type browserReplDaemonResponse struct {
ReplID string `json:"repl_id"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
ErrorCode string `json:"error_code,omitempty"`
Stack *string `json:"stack,omitempty"`
Content []json.RawMessage `json:"content,omitempty"`
ContentTruncated bool `json:"content_truncated"`
Expand Down Expand Up @@ -490,7 +539,6 @@ func (m *browserReplManager) executeLocked(ctx context.Context, request *browser
if resp.ReplID != child.id {
return nil, fmt.Errorf("response repl_id mismatch: expected %s, got %s", child.id, resp.ReplID)
}

return &resp, nil
}

Expand Down Expand Up @@ -531,18 +579,30 @@ func browserReplTerminatedResponse(replID string, err error, durationMs int) oap
}

// StrictBrowserReplBodyMiddleware enforces additionalProperties: false on
// POST /repl. The generated strict-server decoder silently drops
// unknown fields, so without this middleware a request like
// {"code":"1","bogus":1} would be accepted despite the published schema.
// Malformed JSON and type errors are left to the strict handler's own 400
// handling; only unknown fields are policed here.
// Browser REPL-owned request bodies. The generated strict-server decoder
// silently drops unknown fields, so malformed extensions need an explicit
// check before dispatch.
func StrictBrowserReplBodyMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/repl" || r.Body == nil {
if r.Body == nil {
next.ServeHTTP(w, r)
return
}
limitedBody := http.MaxBytesReader(w, r.Body, maxBrowserReplBodyBytes)
var probe any
maxBytes := int64(0)
switch {
case r.Method == http.MethodPost && r.URL.Path == "/repl":
probe = &oapi.BrowserReplRequest{}
maxBytes = maxBrowserReplBodyBytes
case r.Method == http.MethodPost && r.URL.Path == "/webmcp/custom-tools":
probe = &oapi.AddCustomWebMCPToolsRequest{}
maxBytes = maxCustomWebMCPRequestBytes
default:
next.ServeHTTP(w, r)
return
}

limitedBody := http.MaxBytesReader(w, r.Body, maxBytes)
body, err := io.ReadAll(limitedBody)
_ = r.Body.Close()
if err != nil {
Expand All @@ -551,7 +611,7 @@ func StrictBrowserReplBodyMiddleware(next http.Handler) http.Handler {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusRequestEntityTooLarge)
_ = json.NewEncoder(w).Encode(oapi.BadRequestError{
Message: fmt.Sprintf("request body exceeds %d bytes", maxBrowserReplBodyBytes),
Message: fmt.Sprintf("request body exceeds %d bytes", maxBytes),
})
return
}
Expand All @@ -562,8 +622,7 @@ func StrictBrowserReplBodyMiddleware(next http.Handler) http.Handler {

dec := json.NewDecoder(bytes.NewReader(body))
dec.DisallowUnknownFields()
var probe oapi.BrowserReplRequest
if err := dec.Decode(&probe); err != nil && strings.HasPrefix(err.Error(), "json: unknown field") {
if err := dec.Decode(probe); err != nil && strings.HasPrefix(err.Error(), "json: unknown field") {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(oapi.BadRequestError{
Expand Down
26 changes: 19 additions & 7 deletions server/cmd/api/api/browser_repl_cells_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,16 @@ func TestBrowserReplHelpEmitsAndReturnsMethodDocumentation(t *testing.T) {
requireExec(t, svc, `repl.write(JSON.stringify(clickHelp.includes("visible, enabled, stable")))`, true)
}

func TestBrowserReplCanPersistPatchrightAndPlaywrightCoreImports(t *testing.T) {
func TestBrowserReplCanPersistPinnedRuntimeImports(t *testing.T) {
svc := newBrowserReplSvc(t)
requireExec(t, svc, `var playwright = await import("patchright"); var playwrightReference = playwright; var vanillaPlaywright = await import("playwright-core")`, nil)
requireExec(t, svc, `repl.write(JSON.stringify({ same: playwright === playwrightReference, patchrightConnect: typeof playwright.chromium.connectOverCDP, playwrightConnect: typeof vanillaPlaywright.chromium.connectOverCDP, endpoint: process.env.CDP_ENDPOINT }))`, map[string]interface{}{
"same": true,
"patchrightConnect": "function",
"playwrightConnect": "function",
"endpoint": "ws://127.0.0.1:9222",
requireExec(t, svc, `var playwright = await import("patchright"); var playwrightReference = playwright; var vanillaPlaywright = await import("playwright-core"); var mcpCore = await import("@modelcontextprotocol/core"); var mcpServer = await import("@modelcontextprotocol/server/validators/ajv")`, nil)
requireExec(t, svc, `repl.write(JSON.stringify({ same: playwright === playwrightReference, patchrightConnect: typeof playwright.chromium.connectOverCDP, playwrightConnect: typeof vanillaPlaywright.chromium.connectOverCDP, toolSchema: typeof mcpCore.ToolSchema.safeParse, jsonSchemaValidator: typeof mcpServer.AjvJsonSchemaValidator, endpoint: process.env.CDP_ENDPOINT }))`, map[string]interface{}{
"same": true,
"patchrightConnect": "function",
"playwrightConnect": "function",
"toolSchema": "function",
"jsonSchemaValidator": "function",
"endpoint": "ws://127.0.0.1:9222",
})
}

Expand Down Expand Up @@ -407,6 +409,16 @@ func TestStrictBrowserReplBodyMiddleware(t *testing.T) {
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/repl", strings.NewReader(`{nope`)))
require.Equal(t, http.StatusOK, rec.Code)

rec = httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/webmcp/custom-tools", strings.NewReader(`{"namespace":"example.com","source":"","bogus":1}`)))
require.Equal(t, http.StatusBadRequest, rec.Code)
require.Contains(t, rec.Body.String(), `unknown field \"bogus\"`)

rec = httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/webmcp/custom-tools", strings.NewReader(`{"namespace":"example.com","source":"[]"}`)))
require.Equal(t, http.StatusOK, rec.Code)
require.JSONEq(t, `{"namespace":"example.com","source":"[]"}`, rec.Body.String())

rec = httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/repl", nil))
require.Equal(t, http.StatusOK, rec.Code)
Expand Down
Loading
Loading