diff --git a/server/lib/webmcpclient/client.go b/server/lib/webmcpclient/client.go index 10dc02ae..29ef39bb 100644 --- a/server/lib/webmcpclient/client.go +++ b/server/lib/webmcpclient/client.go @@ -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() @@ -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.. to avoid collisions with page // tools, while discovery exposes the original name and the generated ID separately. diff --git a/server/lib/webmcpclient/client_test.go b/server/lib/webmcpclient/client_test.go index ecc0df15..b22c3ebc 100644 --- a/server/lib/webmcpclient/client_test.go +++ b/server/lib/webmcpclient/client_test.go @@ -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)