feat(frontend): Display MCP tool name and arguments as JSON in MCPObservation when visualized in frontend (#8644)

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Xingyao Wang 2025-05-24 01:21:22 +08:00 committed by GitHub
parent b0b5a6c2a1
commit 5e43dbadcb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 211 additions and 47 deletions

View File

@ -8,7 +8,7 @@
* - Please do NOT serve this file on production.
*/
const PACKAGE_VERSION = '2.7.6'
const PACKAGE_VERSION = '2.8.4'
const INTEGRITY_CHECKSUM = '00729d72e3b82faf54ca8b9621dbb96f'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()

View File

@ -46,14 +46,6 @@ const getBrowseObservationContent = (event: BrowseObservation) => {
return contentDetails;
};
const getMcpObservationContent = (event: OpenHandsObservation): string => {
let { content } = event;
if (content.length > MAX_CONTENT_LENGTH) {
content = `${content.slice(0, MAX_CONTENT_LENGTH)}...`;
}
return `**Output:**\n\`\`\`\n${content.trim() || i18n.t("OBSERVATION$MCP_NO_OUTPUT")}\n\`\`\``;
};
const getRecallObservationContent = (event: RecallObservation): string => {
let content = "";
@ -124,8 +116,6 @@ export const getObservationContent = (event: OpenHandsObservation): string => {
return getCommandObservationContent(event);
case "browse":
return getBrowseObservationContent(event);
case "mcp":
return getMcpObservationContent(event);
case "recall":
return getRecallObservationContent(event);
default:

View File

@ -8,11 +8,13 @@ import {
isOpenHandsObservation,
isFinishAction,
isRejectObservation,
isMcpObservation,
} from "#/types/core/guards";
import { OpenHandsObservation } from "#/types/core/observations";
import { ImageCarousel } from "../images/image-carousel";
import { ChatMessage } from "./chat-message";
import { ErrorMessage } from "./error-message";
import { MCPObservationContent } from "./mcp-observation-content";
import { getObservationResult } from "./event-content-helpers/get-observation-result";
import { getEventContent } from "./event-content-helpers/get-event-content";
import { GenericEventMessage } from "./generic-event-message";
@ -78,6 +80,19 @@ export function EventMessage({
return <ChatMessage type="agent" message={event.content} />;
}
if (isMcpObservation(event)) {
return (
<div>
<GenericEventMessage
title={getEventContent(event).title}
details={<MCPObservationContent event={event} />}
success={getObservationResult(event)}
/>
{shouldShowConfirmationButtons && <ConfirmationButtons />}
</div>
);
}
return (
<div>
{isOpenHandsAction(event) && hasThoughtProperty(event.args) && (

View File

@ -10,7 +10,7 @@ import { ObservationResultStatus } from "./event-content-helpers/get-observation
interface GenericEventMessageProps {
title: React.ReactNode;
details: string;
details: string | React.ReactNode;
success?: ObservationResultStatus;
}
@ -44,18 +44,21 @@ export function GenericEventMessage({
{success && <SuccessIndicator status={success} />}
</div>
{showDetails && (
<Markdown
components={{
code,
ul,
ol,
}}
remarkPlugins={[remarkGfm]}
>
{details}
</Markdown>
)}
{showDetails &&
(typeof details === "string" ? (
<Markdown
components={{
code,
ul,
ol,
}}
remarkPlugins={[remarkGfm]}
>
{details}
</Markdown>
) : (
details
))}
</div>
);
}

View File

@ -0,0 +1,73 @@
import React from "react";
import ReactJsonView from "@microlink/react-json-view";
import { useTranslation } from "react-i18next";
import { MCPObservation } from "#/types/core/observations";
import { JSON_VIEW_THEME } from "#/utils/constants";
interface MCPObservationContentProps {
event: MCPObservation;
}
export function MCPObservationContent({ event }: MCPObservationContentProps) {
const { t } = useTranslation();
// Parse the content as JSON if possible
let outputData: unknown;
try {
outputData = JSON.parse(event.content);
} catch (e) {
// If parsing fails, use the raw content
outputData = event.content;
}
const hasArguments =
event.extras.arguments && Object.keys(event.extras.arguments).length > 0;
return (
<div className="flex flex-col gap-4">
{/* Arguments section */}
{hasArguments && (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-300">
{t("MCP_OBSERVATION$ARGUMENTS")}
</h3>
</div>
<div className="p-3 bg-gray-900 rounded-md overflow-auto text-gray-300 max-h-[200px] shadow-inner">
<ReactJsonView
name={false}
src={event.extras.arguments}
theme={JSON_VIEW_THEME}
collapsed={1}
displayDataTypes={false}
/>
</div>
</div>
)}
{/* Output section */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-300">
{t("MCP_OBSERVATION$OUTPUT")}
</h3>
</div>
<div className="p-3 bg-gray-900 rounded-md overflow-auto text-gray-300 max-h-[300px] shadow-inner">
{typeof outputData === "object" && outputData !== null ? (
<ReactJsonView
name={false}
src={outputData}
theme={JSON_VIEW_THEME}
collapsed={1}
displayDataTypes={false}
/>
) : (
<pre className="whitespace-pre-wrap">
{event.content.trim() || t("OBSERVATION$MCP_NO_OUTPUT")}
</pre>
)}
</div>
</div>
</div>
);
}

View File

@ -6,26 +6,7 @@ import { BaseModalTitle } from "#/components/shared/modals/confirmation-modals/b
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { ModalBody } from "#/components/shared/modals/modal-body";
import { cn } from "#/utils/utils";
// Custom JSON viewer theme that matches our application theme
const jsonViewTheme = {
base00: "transparent", // background
base01: "#2d2d2d", // lighter background
base02: "#4e4e4e", // selection background
base03: "#6c6c6c", // comments, invisibles
base04: "#969896", // dark foreground
base05: "#d9d9d9", // default foreground
base06: "#e8e8e8", // light foreground
base07: "#ffffff", // light background
base08: "#ff5370", // variables, red
base09: "#f78c6c", // integers, orange
base0A: "#ffcb6b", // booleans, yellow
base0B: "#c3e88d", // strings, green
base0C: "#89ddff", // support, cyan
base0D: "#82aaff", // functions, blue
base0E: "#c792ea", // keywords, purple
base0F: "#ff5370", // deprecated, red
};
import { JSON_VIEW_THEME } from "#/utils/constants";
interface SystemMessageModalProps {
isOpen: boolean;
@ -207,8 +188,9 @@ export function SystemMessageModal({
</h4>
<div className="text-sm mt-2 p-3 bg-gray-900 rounded-md overflow-auto text-gray-300 max-h-[400px] shadow-inner">
<ReactJsonView
name={false}
src={parameters}
theme={jsonViewTheme}
theme={JSON_VIEW_THEME}
/>
</div>
</div>

View File

@ -11,6 +11,8 @@ export enum I18nKey {
EVENT$UNKNOWN_EVENT = "EVENT$UNKNOWN_EVENT",
OBSERVATION$COMMAND_NO_OUTPUT = "OBSERVATION$COMMAND_NO_OUTPUT",
OBSERVATION$MCP_NO_OUTPUT = "OBSERVATION$MCP_NO_OUTPUT",
MCP_OBSERVATION$ARGUMENTS = "MCP_OBSERVATION$ARGUMENTS",
MCP_OBSERVATION$OUTPUT = "MCP_OBSERVATION$OUTPUT",
OBSERVATION$ERROR_PREFIX = "OBSERVATION$ERROR_PREFIX",
TASK$ADDRESSING_TASK = "TASK$ADDRESSING_TASK",
SECRETS$SECRET_VALUE_REQUIRED = "SECRETS$SECRET_VALUE_REQUIRED",

View File

@ -175,6 +175,38 @@
"de": "[MCP-Tool wurde ohne Ausgabe ausgeführt]",
"uk": "[Інструмент MCP завершив виконання без виводу]"
},
"MCP_OBSERVATION$ARGUMENTS": {
"en": "Arguments",
"ja": "引数",
"zh-CN": "参数",
"zh-TW": "參數",
"ko-KR": "인수",
"no": "Argumenter",
"it": "Argomenti",
"pt": "Argumentos",
"es": "Argumentos",
"ar": "المعاملات",
"fr": "Arguments",
"tr": "Argümanlar",
"de": "Argumente",
"uk": "Аргументи"
},
"MCP_OBSERVATION$OUTPUT": {
"en": "Output",
"ja": "出力",
"zh-CN": "输出",
"zh-TW": "輸出",
"ko-KR": "출력",
"no": "Utdata",
"it": "Output",
"pt": "Saída",
"es": "Salida",
"ar": "المخرجات",
"fr": "Sortie",
"tr": ıktı",
"de": "Ausgabe",
"uk": "Вивід"
},
"OBSERVATION$ERROR_PREFIX": {
"en": "error:",
"ja": "エラー:",

View File

@ -8,6 +8,7 @@ import {
import {
CommandObservation,
ErrorObservation,
MCPObservation,
OpenHandsObservation,
} from "./observations";
@ -57,3 +58,8 @@ export const isRejectObservation = (
event: OpenHandsParsedEvent,
): event is OpenHandsObservation =>
isOpenHandsObservation(event) && event.observation === "user_rejected";
export const isMcpObservation = (
event: OpenHandsParsedEvent,
): event is MCPObservation =>
isOpenHandsObservation(event) && event.observation === "mcp";

View File

@ -135,6 +135,7 @@ export interface MCPObservation extends OpenHandsObservationEvent<"mcp"> {
source: "agent";
extras: {
name: string;
arguments: Record<string, unknown>;
};
}

View File

@ -9,3 +9,22 @@ export const ASSET_FILE_TYPES = [
".webm",
".ogg",
];
export const JSON_VIEW_THEME = {
base00: "transparent", // background
base01: "#2d2d2d", // lighter background
base02: "#4e4e4e", // selection background
base03: "#6c6c6c", // comments, invisibles
base04: "#969896", // dark foreground
base05: "#d9d9d9", // default foreground
base06: "#e8e8e8", // light foreground
base07: "#ffffff", // light background
base08: "#ff5370", // variables, red
base09: "#f78c6c", // integers, orange
base0A: "#ffcb6b", // booleans, yellow
base0B: "#c3e88d", // strings, green
base0C: "#89ddff", // support, cyan
base0D: "#82aaff", // functions, blue
base0E: "#c792ea", // keywords, purple
base0F: "#ff5370", // deprecated, red
};

View File

@ -1,4 +1,5 @@
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Any
from openhands.core.schema import ObservationType
from openhands.events.observation.observation import Observation
@ -10,6 +11,7 @@ class MCPObservation(Observation):
observation: str = ObservationType.MCP
name: str = '' # The name of the MCP tool that was called
arguments: dict[str, Any] = field(default_factory=dict) # The arguments passed to the MCP tool
@property
def message(self) -> str:

View File

@ -155,7 +155,9 @@ async def call_tool_mcp(mcp_clients: list[MCPClient], action: MCPAction) -> Obse
logger.debug(f'MCP response: {response}')
return MCPObservation(
content=json.dumps(response.model_dump(mode='json')), name=action.name
content=json.dumps(response.model_dump(mode='json')),
name=action.name,
arguments=action.arguments,
)

View File

@ -106,3 +106,40 @@ def test_mcp_action_with_complex_arguments():
assert 'nested_arg' in message
assert 'inner_key' in message
assert 'inner_value' in message
def test_mcp_observation_with_arguments():
"""Test MCPObservation with arguments."""
complex_args = {
'simple_arg': 'value',
'number_arg': 42,
'boolean_arg': True,
'nested_arg': {'inner_key': 'inner_value', 'inner_list': [1, 2, 3]},
'list_arg': ['a', 'b', 'c'],
}
observation = MCPObservation(
content=json.dumps({'result': 'success', 'data': 'test data'}),
name='test_tool',
arguments=complex_args,
)
assert observation.content == json.dumps({'result': 'success', 'data': 'test data'})
assert observation.observation == ObservationType.MCP
assert observation.name == 'test_tool'
assert observation.arguments == complex_args
assert observation.arguments['nested_arg']['inner_key'] == 'inner_value'
assert observation.arguments['list_arg'] == ['a', 'b', 'c']
# Test serialization
from openhands.events.serialization import event_to_dict
serialized = event_to_dict(observation)
assert serialized['observation'] == ObservationType.MCP
assert serialized['content'] == json.dumps(
{'result': 'success', 'data': 'test data'}
)
assert serialized['extras']['name'] == 'test_tool'
assert serialized['extras']['arguments'] == complex_args
assert serialized['extras']['arguments']['nested_arg']['inner_key'] == 'inner_value'