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
30 changes: 30 additions & 0 deletions server/lib/webmcpclient/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ func (c *connection) handleProtocolEvent(message cdpclient.Message) {
if response.ErrorText == "" && response.Exception != nil {
response.ErrorText = exceptionText(response.Exception)
}
response.Output = decodeLatin1Output(response.Output)
key := invocationKey{sessionID: message.SessionID, invocationID: response.InvocationID}
c.stateMu.Lock()
c.pruneAbandonedInvocationsLocked()
Expand Down Expand Up @@ -231,6 +232,35 @@ func exceptionText(exception *exceptionDetails) string {
return text
}

// decodeLatin1Output parses JSON output that Chromium left as a string.
// Before Chromium 158, a result whose characters all fit in Latin-1, with at
// least one in U+0080-U+00FF, was handed to a UTF-8 JSON parser as raw Latin-1
// bytes; the parse failed and toolResponded carried the JSON text instead of
// the value. Later versions parse the same text, so this matches them.
func decodeLatin1Output(output any) any {
text, ok := output.(string)
if !ok {
return output
}
latin1 := false
for _, r := range text {
if r > 0xFF {
return output
}
if r >= 0x80 {
latin1 = true
}
}
if !latin1 {
return output
}
var decoded any
if json.Unmarshal([]byte(text), &decoded) != nil {
return output
}
return decoded
}

// customToolIdentity decodes the hidden name used for a custom registration.
// The browser registers custom.<ct_CUID2>.<name> to avoid collisions with page
// tools, while discovery exposes the original name and the generated ID separately.
Expand Down
26 changes: 26 additions & 0 deletions server/lib/webmcpclient/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -669,6 +669,32 @@ func TestToolResponsesAreScopedBySession(t *testing.T) {
require.Equal(t, "iframe-session", client.invocations[invocationKey{sessionID: "iframe-session", invocationID: "invocation-1"}].Output)
}

func TestToolResponseDecodesLatin1JSONOutput(t *testing.T) {
client := &connection{
invocations: make(map[invocationKey]invocationResponse),
waitingInvocations: make(map[invocationKey]string),
abandonedInvocations: make(map[invocationKey]time.Time),
stateChangedCh: make(chan struct{}, 1),
}
cases := map[string]struct {
output any
want any
}{
"latin1 json": {`{"content":[{"type":"text","text":"600×400"}]}`, map[string]any{"content": []any{map[string]any{"type": "text", "text": "600×400"}}}},
"latin1 json string": {`"Café"`, "Café"},
"latin1 text": {"Café", "Café"},
"ascii json text": {`{"a":1}`, `{"a":1}`},
"wider than latin1": {`{"text":"Café—"}`, `{"text":"Café—"}`},
"object": {map[string]any{"text": "Café"}, map[string]any{"text": "Café"}},
}
for name, tc := range cases {
params, err := json.Marshal(invocationResponse{InvocationID: name, Status: "Completed", Output: tc.output})
require.NoError(t, err)
client.handleProtocolEvent(cdpclient.Message{Method: "WebMCP.toolResponded", SessionID: "page-session", Params: params})
require.Equal(t, tc.want, client.invocations[invocationKey{sessionID: "page-session", invocationID: name}].Output, name)
}
}

func TestManagerReconnectsWhenChromiumUpstreamChanges(t *testing.T) {
first := newFakeCDP(t, false)
second := newFakeCDP(t, false)
Expand Down
Loading