diff --git a/apps/app/src/components/chat/message-list.tsx b/apps/app/src/components/chat/message-list.tsx index 97228fea17..5bf046c8e5 100644 --- a/apps/app/src/components/chat/message-list.tsx +++ b/apps/app/src/components/chat/message-list.tsx @@ -82,6 +82,7 @@ import { getActiveToolLabel, } from "@/lib/tool-activity" import { cn } from "@/lib/utils" +import type { ToolInvocationKnownServer } from "@/lib/tool-invocation-origin" import { groupMessages, isMessageGroup, getLastTextPart, getAssistantRenderGroups, getFileTitle, getMediaBadge, getMessageCreated, formatMessageTimestamp, type UIMessageWithIndex, getMessagesText, getSafeFileDownloadUrl } from "./utils" const SEARCH_HIGHLIGHT_MARK_CLASS = "rounded px-0.5 bg-amber-4/70 text-current" @@ -105,6 +106,7 @@ function MessageTimestamp({ message, className }: { message: UIMessage; classNam interface ToolMessageProps { part: ToolUIPart | DynamicToolUIPart + knownServers?: readonly ToolInvocationKnownServer[] } /** @@ -131,11 +133,11 @@ class ToolMessage extends React.Component
Tool step unavailable
) } - return + return } } -const ToolMessageInner = ({ part }: ToolMessageProps) => { +const ToolMessageInner = ({ part, knownServers }: ToolMessageProps) => { if (isBashToolPart(part)) { return } @@ -192,7 +194,7 @@ const ToolMessageInner = ({ part }: ToolMessageProps) => { return } - return + return } const isEmptyMessage = (message: UIMessage): boolean => message.parts.length === 0 @@ -334,10 +336,11 @@ type AssistantMessageProps = { isLastMessage: boolean isStreaming: boolean isLastStep: boolean + knownServers?: readonly ToolInvocationKnownServer[] } const AssistantMessage = React.memo( - ({ message }: AssistantMessageProps) => { + ({ message, knownServers }: AssistantMessageProps) => { const { showThinking, highlightQuery } = useMessageList() const assistantRenderGroups = React.useMemo( () => getAssistantRenderGroups(message.parts, showThinking), @@ -387,7 +390,7 @@ const AssistantMessage = React.memo( return (
- +
) })} @@ -567,10 +570,11 @@ type MessageComponentProps = { isLastMessage: boolean isStreaming: boolean isLastStep: boolean + knownServers?: readonly ToolInvocationKnownServer[] } const MessageComponent = React.memo( - ({ message, isLastMessage, isStreaming, isLastStep }: MessageComponentProps) => { + ({ message, isLastMessage, isStreaming, isLastStep, knownServers }: MessageComponentProps) => { if (isSessionErrorMessage(message)) { return } @@ -591,6 +595,7 @@ const MessageComponent = React.memo( isLastMessage={isLastMessage} isStreaming={isStreaming} isLastStep={isLastStep} + knownServers={knownServers} /> ) } @@ -731,12 +736,14 @@ interface AssistantMessageGroupProps { items: UIMessageWithIndex[] messages: UIMessage[] isStreaming: boolean + knownServers?: readonly ToolInvocationKnownServer[] } function MessageGroup({ items, messages, isStreaming, + knownServers, }: AssistantMessageGroupProps) { const { onRevertToUserMessage, onForkAtMessage } = useMessageList() const lastItem = items[items.length - 1] @@ -786,6 +793,7 @@ function MessageGroup({ isLastMessage={isLastMessage} isStreaming={isLastMessage && isStreaming} isLastStep={groupIndex === items.length - 1} + knownServers={knownServers} /> @@ -842,9 +850,10 @@ interface MessageListProps { messages: UIMessage[] status: ThreadStatus retryStatus?: RetryStatus | null + knownMcpServers?: readonly ToolInvocationKnownServer[] } -export function MessageList({ messages, status, retryStatus }: MessageListProps) { +export function MessageList({ messages, status, retryStatus, knownMcpServers }: MessageListProps) { const isStreaming = status === "streaming" || status === "retrying" const items = React.useMemo(() => groupMessages(messages, status), [messages, status]); const error = useSessionErrorMessage(); @@ -865,6 +874,7 @@ export function MessageList({ messages, status, retryStatus }: MessageListProps) items={item.messages} messages={messages} isStreaming={isStreaming} + knownServers={knownMcpServers} /> ) } @@ -880,6 +890,7 @@ export function MessageList({ messages, status, retryStatus }: MessageListProps) isLastMessage={isLastMessage} isStreaming={isLastMessage && isStreaming} isLastStep={isLastStep} + knownServers={knownMcpServers} /> diff --git a/apps/app/src/components/ui/tool.tsx b/apps/app/src/components/ui/tool.tsx index 959956f301..542f0e93c0 100644 --- a/apps/app/src/components/ui/tool.tsx +++ b/apps/app/src/components/ui/tool.tsx @@ -22,6 +22,7 @@ import { Wrench, } from "lucide-react" import type { DynamicToolUIPart, ToolUIPart } from "ai" +import { toolInvocationOrigin, type ToolInvocationKnownServer } from "@/lib/tool-invocation-origin" function toolIcon(part: ToolPart) { const name = part.type === "dynamic-tool" ? part.toolName : part.type @@ -56,6 +57,7 @@ export type ToolPart = ToolUIPart | DynamicToolUIPart export type ToolProps = { title?: string toolPart: ToolPart + knownServers?: readonly ToolInvocationKnownServer[] defaultOpen?: boolean className?: string } @@ -116,11 +118,14 @@ function DiffLines({ diff }: { diff: string }) { ) } -const Tool = ({ title, toolPart, defaultOpen = false, className }: ToolProps) => { +const Tool = ({ title, toolPart, knownServers, defaultOpen = false, className }: ToolProps) => { const { state, input } = toolPart const inFlight = isToolPartInFlight(toolPart) const isError = state === "output-error" - const label = title ?? getToolActivityLabel(toolPart) + const origin = toolPart.type === "dynamic-tool" + ? toolInvocationOrigin(toolPart.toolName, input, knownServers) + : null + const label = title ?? (origin?.connectionName ? origin.displayTool : getToolActivityLabel(toolPart)) const hasInput = input !== null && input !== undefined const hasOutput = "output" in toolPart && toolPart.output !== undefined const inputDiff = getInputDiff(input) @@ -143,6 +148,11 @@ const Tool = ({ title, toolPart, defaultOpen = false, className }: ToolProps) => + {origin?.connectionName ? ( + + {origin.connectionName} + + ) : null} {label} {isError ? ( failed diff --git a/apps/app/src/lib/tool-invocation-origin.ts b/apps/app/src/lib/tool-invocation-origin.ts new file mode 100644 index 0000000000..edcff6dfd2 --- /dev/null +++ b/apps/app/src/lib/tool-invocation-origin.ts @@ -0,0 +1,193 @@ +export type ToolInvocationKnownServer = { + id?: string | null; + name: string; + displayName?: string | null; +}; + +export type ToolInvocationOrigin = { + connectionName?: string; + displayTool: string; +}; + +type DirectToolOrigin = ToolInvocationOrigin & { + serverName: string; +}; + +const OPENWORK_CLOUD_SERVER_NAME = "openwork-cloud"; +const OPENWORK_CLOUD_LABEL = "OpenWork Cloud"; +const EXECUTE_CAPABILITY_TOOL_NAME = "execute_capability"; + +const builtInToolNames = new Set([ + "apply_patch", + "bash", + "edit", + "env_var_request", + "glob", + "grep", + "lsp", + "question", + "read", + "request_env_var", + "skill", + "task", + "todowrite", + "webfetch", + "websearch", + "write", +]); + +const nonConnectionPrefixes = new Set([ + "create", + "delete", + "diagnostic", + "diagnostics", + "execute", + "fetch", + "get", + "list", + "lookup", + "mutate", + "read", + "search", + "summarize", + "synthetic", + "update", + "write", +]); + +const wordLabels: Record = { + api: "API", + mcp: "MCP", + oauth: "OAuth", + openwork: "OpenWork", + servicenow: "ServiceNow", + ui: "UI", +}; + +function formatNameWord(word: string) { + const lower = word.toLowerCase(); + const special = wordLabels[lower]; + if (special) return special; + return lower.charAt(0).toUpperCase() + lower.slice(1); +} + +function formatConnectionName(value: string) { + const words = value.replace(/[_-]+/g, " ").trim().split(/\s+/).filter(Boolean); + return words.length > 0 ? words.map(formatNameWord).join(" ") : value; +} + +function serverCandidates(server: ToolInvocationKnownServer) { + const candidates: string[] = []; + const name = server.name.trim(); + const id = server.id?.trim() ?? ""; + if (name) candidates.push(name); + if (id && id !== name) candidates.push(id); + return candidates; +} + +function candidateLength(server: ToolInvocationKnownServer) { + return Math.max(...serverCandidates(server).map((candidate) => candidate.length), 0); +} + +function displayNameForServer(server: ToolInvocationKnownServer) { + const explicit = server.displayName?.trim(); + if (explicit) return explicit; + return formatConnectionName(server.name); +} + +function sortedKnownServers(knownServers: readonly ToolInvocationKnownServer[]) { + return [...knownServers].sort((left, right) => candidateLength(right) - candidateLength(left)); +} + +function resolveKnownConnectionName(id: string, knownServers: readonly ToolInvocationKnownServer[]) { + const trimmed = id.trim(); + if (!trimmed) return null; + const match = knownServers.find((server) => { + const serverId = server.id?.trim() ?? ""; + return serverId === trimmed || server.name.trim() === trimmed; + }); + return match ? displayNameForServer(match) : null; +} + +function directToolOrigin(toolName: string, knownServers: readonly ToolInvocationKnownServer[]): DirectToolOrigin | null { + for (const server of sortedKnownServers(knownServers)) { + for (const candidate of serverCandidates(server)) { + const prefix = `${candidate}_`; + if (!toolName.startsWith(prefix)) continue; + const displayTool = toolName.slice(prefix.length); + if (!displayTool) continue; + return { + connectionName: displayNameForServer(server), + displayTool, + serverName: server.name.trim(), + }; + } + } + + const openworkPrefix = `${OPENWORK_CLOUD_SERVER_NAME}_`; + if (toolName.startsWith(openworkPrefix)) { + const displayTool = toolName.slice(openworkPrefix.length); + if (displayTool) { + return { + connectionName: OPENWORK_CLOUD_LABEL, + displayTool, + serverName: OPENWORK_CLOUD_SERVER_NAME, + }; + } + } + + return null; +} + +function fallbackDirectToolOrigin(toolName: string): DirectToolOrigin | null { + const separator = toolName.indexOf("_"); + if (separator <= 0 || separator === toolName.length - 1) return null; + const serverName = toolName.slice(0, separator).trim(); + const displayTool = toolName.slice(separator + 1).trim(); + if (!serverName || !displayTool || nonConnectionPrefixes.has(serverName.toLowerCase())) return null; + return { + connectionName: formatConnectionName(serverName), + displayTool, + serverName, + }; +} + +function namedArgument(value: unknown) { + if (typeof value !== "object" || value === null || Array.isArray(value) || !("name" in value)) return null; + return typeof value.name === "string" ? value.name : null; +} + +function mcpCapabilityNameParts(value: string) { + const match = value.match(/^mcp:([^:]+):(.+)$/); + const connectionId = match?.[1]?.trim() ?? ""; + const toolName = match?.[2]?.trim() ?? ""; + if (!connectionId || !toolName) return null; + return { connectionId, toolName }; +} + +export function toolInvocationOrigin( + toolName: string, + args?: unknown, + knownServers: readonly ToolInvocationKnownServer[] = [], +): ToolInvocationOrigin { + if (builtInToolNames.has(toolName)) return { displayTool: toolName }; + + const direct = directToolOrigin(toolName, knownServers) ?? fallbackDirectToolOrigin(toolName); + if (!direct) return { displayTool: toolName }; + + if (direct.serverName === OPENWORK_CLOUD_SERVER_NAME && direct.displayTool === EXECUTE_CAPABILITY_TOOL_NAME) { + const parts = mcpCapabilityNameParts(namedArgument(args) ?? ""); + if (parts) { + const connectionName = resolveKnownConnectionName(parts.connectionId, knownServers) ?? parts.connectionId; + return { + connectionName: `${OPENWORK_CLOUD_LABEL} → ${connectionName}`, + displayTool: parts.toolName, + }; + } + } + + return { + connectionName: direct.connectionName, + displayTool: direct.displayTool, + }; +} diff --git a/apps/app/src/react-app/domains/connections/connection-display-state.ts b/apps/app/src/react-app/domains/connections/connection-display-state.ts new file mode 100644 index 0000000000..54a058e4f8 --- /dev/null +++ b/apps/app/src/react-app/domains/connections/connection-display-state.ts @@ -0,0 +1,67 @@ +import type { McpStatus } from "@/app/types"; + +export type ConnectionDisplayState = + | "configured" + | "auth_required" + | "protocol_ready" + | "error" + | "disabled"; + +export type ConnectionDisplayStateLabelKey = + | "mcp.friendly_status_offline" + | "mcp.friendly_status_needs_signin" + | "mcp.friendly_status_ready" + | "mcp.friendly_status_issue" + | "mcp.friendly_status_paused"; + +export type ConnectionDisplayTone = "neutral" | "warning" | "success" | "error"; + +export type ConnectionDisplayStateInput = { + configured: boolean; + enabled?: boolean; + status?: McpStatus["status"] | null; + needsAuth?: boolean; + failed?: boolean; +}; + +export function connectionDisplayState(input: ConnectionDisplayStateInput): ConnectionDisplayState | null { + if (input.enabled === false || input.status === "disabled") return "disabled"; + if ( + input.status === "needs_auth" || + input.status === "needs_client_registration" || + input.needsAuth === true + ) return "auth_required"; + if (input.status === "connected") return "protocol_ready"; + if (input.status === "failed" || input.failed === true) return "error"; + if (input.configured) return "configured"; + return null; +} + +export function connectionDisplayStateLabelKey(state: ConnectionDisplayState): ConnectionDisplayStateLabelKey { + switch (state) { + case "configured": + return "mcp.friendly_status_offline"; + case "auth_required": + return "mcp.friendly_status_needs_signin"; + case "protocol_ready": + return "mcp.friendly_status_ready"; + case "error": + return "mcp.friendly_status_issue"; + case "disabled": + return "mcp.friendly_status_paused"; + } +} + +export function connectionDisplayStateTone(state: ConnectionDisplayState): ConnectionDisplayTone { + switch (state) { + case "protocol_ready": + return "success"; + case "auth_required": + return "warning"; + case "error": + return "error"; + case "configured": + case "disabled": + return "neutral"; + } +} diff --git a/apps/app/src/react-app/domains/session/surface/session-surface.tsx b/apps/app/src/react-app/domains/session/surface/session-surface.tsx index e16f730337..3b62a4a9fe 100644 --- a/apps/app/src/react-app/domains/session/surface/session-surface.tsx +++ b/apps/app/src/react-app/domains/session/surface/session-surface.tsx @@ -477,6 +477,10 @@ export function SessionSurface(props: SessionSurfaceProps) { () => createClient(props.opencodeBaseUrl, undefined, { token: props.openworkToken, mode: "openwork" }), [props.opencodeBaseUrl, props.openworkToken], ); + const toolInvocationKnownServers = useMemo( + () => toolMcpServers.map((server) => ({ name: server.name })), + [toolMcpServers], + ); const snapshotQueryKey = useMemo( () => reactSnapshotKey(props.workspaceId, props.sessionId), @@ -1407,6 +1411,7 @@ export function SessionSurface(props: SessionSurfaceProps) { messages={renderedMessages} status={status} retryStatus={liveStatus.type === "retry" ? liveStatus : null} + knownMcpServers={toolInvocationKnownServers} /> diff --git a/apps/app/src/react-app/domains/settings/pages/connect-view.tsx b/apps/app/src/react-app/domains/settings/pages/connect-view.tsx index 7e6eddb5d5..536832b4da 100644 --- a/apps/app/src/react-app/domains/settings/pages/connect-view.tsx +++ b/apps/app/src/react-app/domains/settings/pages/connect-view.tsx @@ -15,6 +15,11 @@ import { Skeleton } from "@/components/ui/skeleton"; import { t } from "@/i18n"; import { DenSignInSurface } from "@/react-app/domains/cloud/den-signin-surface"; import { useDenAuth, type DenAuthStatus } from "@/react-app/domains/cloud/den-auth-provider"; +import { + connectionDisplayState, + connectionDisplayStateLabelKey, + type ConnectionDisplayState, +} from "@/react-app/domains/connections/connection-display-state"; import { canDisconnectNativeProviderAccount, connectionNeedsReconnect, @@ -494,6 +499,46 @@ function connectGroupLabel(group: Exclude) { } } +function connectionStateChipClass(state: ConnectionDisplayState) { + switch (state) { + case "protocol_ready": + return "bg-green-3 text-green-11"; + case "auth_required": + return "bg-amber-3 text-amber-11"; + case "error": + return "bg-red-3 text-red-11"; + case "configured": + case "disabled": + return "bg-gray-3 text-gray-11"; + } +} + +function ConnectionStateChip(props: { state: ConnectionDisplayState }) { + return ( + + {t(connectionDisplayStateLabelKey(props.state))} + + ); +} + +function connectRowDisplayState(row: ConnectOrganizationRow): ConnectionDisplayState { + if (row.kind === "connection") { + return connectionDisplayState({ + configured: true, + status: row.group === "needs_signin" + ? "needs_auth" + : row.connection.connected || row.connection.connectedForMe + ? "connected" + : undefined, + }) ?? "configured"; + } + + return connectionDisplayState({ + configured: true, + status: row.group === "needs_signin" ? "needs_auth" : row.group === "ready" ? "connected" : undefined, + }) ?? "configured"; +} + function ConnectRowIcon(props: { iconSlug?: string; iconSrc?: string; name: string; serviceUrl?: string }) { const resolved = resolveExtensionIconUrl({ iconSlug: props.iconSlug, iconSrc: props.iconSrc, serviceUrl: props.serviceUrl }); const [failed, setFailed] = useState(false); @@ -569,6 +614,7 @@ function ConnectOrganizationRow(props: { const connecting = connectableConnectionId ? props.connectingId === connectableConnectionId : false; const disconnectableConnectionId = row.kind === "connection" && canDisconnectNativeProviderAccount(row.connection) ? row.connection.id : null; const disconnecting = disconnectableConnectionId ? props.disconnectingId === disconnectableConnectionId : false; + const displayState = connectRowDisplayState(row); return (
{row.group === "needs_signin" && connectableConnectionId ? (
+ +
+ + +
) : disconnectableConnectionId ? (
- - {t("connect.row_chip_ready")} - +
) : ( - - {t("connect.row_chip_ready")} - + )}
); diff --git a/apps/app/src/react-app/domains/settings/pages/mcp-view.tsx b/apps/app/src/react-app/domains/settings/pages/mcp-view.tsx index 24d97c3df4..b39d5d79ff 100644 --- a/apps/app/src/react-app/domains/settings/pages/mcp-view.tsx +++ b/apps/app/src/react-app/domains/settings/pages/mcp-view.tsx @@ -2,7 +2,6 @@ import { useEffect, useReducer, useRef, useState, type SetStateAction } from "react"; import { BookOpen, - CheckCircle2, ChevronDown, CircleAlert, Cloud, @@ -52,6 +51,11 @@ import { ConfirmModal } from "../../../design-system/modals/confirm-modal"; import { AddMcpModal } from "../../connections/modals/add-mcp-modal"; import { ClaudePluginImportModal } from "../../connections/modals/claude-plugin-import-modal"; import { canDisconnectNativeProviderAccount } from "../../connections/native-provider-connections"; +import { + connectionDisplayState, + connectionDisplayStateLabelKey, + type ConnectionDisplayState, +} from "../../connections/connection-display-state"; import type { OpenworkClaudePluginPreview } from "../../../../app/lib/openwork-server"; import { isOpenWorkExtensionEnabled, @@ -67,14 +71,6 @@ import { type McpViewLocalState, } from "./mcp-view-state"; -export type ReactMcpStatus = - | "connected" - | "needs_auth" - | "needs_client_registration" - | "failed" - | "disabled" - | "disconnected"; - export type SkillItem = { name: string; description?: string; @@ -133,49 +129,32 @@ export type McpViewProps = { const builtInExtensionDisabledReason = "Disabled by organization"; -const statusDot = (status: ReactMcpStatus) => { - switch (status) { - case "connected": +const statusDot = (state: ConnectionDisplayState) => { + switch (state) { + case "protocol_ready": return "bg-green-9"; - case "needs_auth": - case "needs_client_registration": + case "auth_required": return "bg-amber-9"; + case "configured": case "disabled": return "bg-gray-8"; - case "disconnected": - return "bg-gray-7"; - default: + case "error": return "bg-red-9"; } }; -const friendlyStatus = (status: ReactMcpStatus) => { - switch (status) { - case "connected": - return t("mcp.friendly_status_ready"); - case "needs_auth": - case "needs_client_registration": - return t("mcp.friendly_status_needs_signin"); - case "disabled": - return t("mcp.friendly_status_paused"); - case "disconnected": - return t("mcp.friendly_status_offline"); - default: - return t("mcp.friendly_status_issue"); - } -}; +const friendlyStatus = (state: ConnectionDisplayState) => t(connectionDisplayStateLabelKey(state)); -const statusBadgeStyle = (status: ReactMcpStatus) => { - switch (status) { - case "connected": +const statusChipStyle = (state: ConnectionDisplayState) => { + switch (state) { + case "protocol_ready": return "bg-green-3 text-green-11"; - case "needs_auth": - case "needs_client_registration": + case "auth_required": return "bg-amber-3 text-amber-11"; + case "configured": case "disabled": - case "disconnected": return "bg-gray-3 text-gray-11"; - default: + case "error": return "bg-red-3 text-red-11"; } }; @@ -437,14 +416,15 @@ export function McpView(props: McpViewProps) { const supportsOauth = (entry: McpServerEntry) => entry.config.type === "remote" && entry.config.oauth !== false; - const resolveStatus = (entry: McpServerEntry): ReactMcpStatus => { - if (entry.config.enabled === false) return "disabled"; - const resolved = props.mcpStatuses[entry.name]; - return resolved?.status ?? "disconnected"; - }; + const resolveDisplayState = (entry: McpServerEntry): ConnectionDisplayState => + connectionDisplayState({ + configured: true, + enabled: entry.config.enabled !== false, + status: props.mcpStatuses[entry.name]?.status, + }) ?? "configured"; const connectedCount = props.mcpServers.filter( - (entry) => resolveStatus(entry) === "connected", + (entry) => resolveDisplayState(entry) === "protocol_ready", ).length; const hiddenCount = quickConnectList.filter((entry) => isOpenWorkExtensionHidden(entry)).length + (props.installedSkills ?? []).filter((skill) => isOpenWorkExtensionHidden(getSkillHiddenId(skill))).length + @@ -658,7 +638,7 @@ export function McpView(props: McpViewProps) { logoutTarget={logoutTarget} togglingMcp={togglingMcp} displayName={displayName} - resolveStatus={resolveStatus} + resolveDisplayState={resolveDisplayState} supportsOauth={supportsOauth} onSelect={props.setSelectedMcp} onAuthorize={props.authorizeMcp} @@ -928,7 +908,7 @@ function McpQuickConnectSection(props: { disabledReasonForEntry: (entry: McpDirectoryInfo) => string | null; isConfigured: (entry: McpDirectoryInfo) => boolean; enablementForEntry?: (entry: McpDirectoryInfo) => { active: boolean; results: EnablementResult[] } | null; - statusForEntry: (entry: McpDirectoryInfo) => { status: ReactMcpStatus } | undefined; + statusForEntry: (entry: McpDirectoryInfo) => McpStatusMap[string] | undefined; onConnect: (entry: McpDirectoryInfo) => void; onDetail: (entry: McpDirectoryInfo) => void; onSkillDetail?: (skill: SkillItem) => void; @@ -1066,7 +1046,7 @@ function McpConfiguredServersSection(props: { logoutTarget: string | null; togglingMcp: string | null; displayName: (name: string) => string; - resolveStatus: (entry: McpServerEntry) => ReactMcpStatus; + resolveDisplayState: (entry: McpServerEntry) => ConnectionDisplayState; supportsOauth: (entry: McpServerEntry) => boolean; onSelect: (name: string | null) => void; onAuthorize: (entry: McpServerEntry) => void; @@ -1094,7 +1074,7 @@ function McpConfiguredServersSection(props: {