diff --git a/src/renderer/analytics/posthog.ts b/src/renderer/analytics/posthog.ts
index 0334875e..a05b48d1 100644
--- a/src/renderer/analytics/posthog.ts
+++ b/src/renderer/analytics/posthog.ts
@@ -47,7 +47,7 @@ function appSummaryProperties(): ProductAnalyticsProperties {
function segmentProperties(
segments: readonly PromptSegment[] | undefined,
): ProductAnalyticsProperties {
- const counts = { text: 0, file: 0, attachment: 0, skill: 0 };
+ const counts = { text: 0, file: 0, attachment: 0, skill: 0, mcp: 0 };
for (const segment of segments ?? []) {
counts[segment.kind] += 1;
}
diff --git a/src/renderer/components/composer/McpMentionChip.ts b/src/renderer/components/composer/McpMentionChip.ts
new file mode 100644
index 00000000..09540148
--- /dev/null
+++ b/src/renderer/components/composer/McpMentionChip.ts
@@ -0,0 +1,37 @@
+/**
+ * Inline, non-editable badge representing an `@`-mentioned MCP server (Browser,
+ * Crossagents, Computer Use, …) inside the composer's contentEditable. The chip
+ * carries the server `id`/`name` on its dataset so {@link serializeToSegments}
+ * can round-trip it to an `mcp` prompt segment, which flattens back to the
+ * `@Name` directive the agent reads for the turn. The glyph mirrors the `Plug`
+ * icon the app uses for MCP plugins elsewhere.
+ */
+export interface McpMentionChipInput {
+ id: string;
+ name: string;
+}
+
+// lucide `plug`, inlined for the DOM chip (mirrors how the skill chip inlines
+// its glyph SVG); the React user-message badge renders the `Plug` component.
+const PLUG_ICON_SVG =
+ '';
+
+export function createMcpMentionChipElement(input: McpMentionChipInput): HTMLSpanElement {
+ const chip = document.createElement("span");
+ chip.contentEditable = "false";
+ chip.dataset.mcpId = input.id;
+ chip.dataset.mcpName = input.name;
+ chip.className = "poracode-slash-chip";
+
+ const glyph = document.createElement("span");
+ glyph.className = "poracode-slash-chip__slash";
+ glyph.innerHTML = PLUG_ICON_SVG;
+ chip.appendChild(glyph);
+
+ const name = document.createElement("span");
+ name.className = "poracode-slash-chip__name";
+ name.textContent = input.name;
+ chip.appendChild(name);
+
+ return chip;
+}
diff --git a/src/renderer/components/composer/MentionInput.test.ts b/src/renderer/components/composer/MentionInput.test.ts
index e5583e91..09dbcc04 100644
--- a/src/renderer/components/composer/MentionInput.test.ts
+++ b/src/renderer/components/composer/MentionInput.test.ts
@@ -168,11 +168,13 @@ describe("MCP mention selection", () => {
onSubmit: vi.fn<(segments: PromptSegment[]) => void>(),
};
- it("keeps an enabled MCP mention in the prompt for the agent", () => {
+ it("inserts an enabled MCP mention as a badge that flattens to the agent directive", () => {
const onMcpMentionSelect = vi.fn<(id: string) => void>();
+ const ref = createRef();
render(
createElement(MentionInput, {
...baseProps,
+ ref,
mcpMentions: [
{
id: "browser",
@@ -189,7 +191,12 @@ describe("MCP mention selection", () => {
const editor = typeMention("bro");
fireEvent.keyDown(editor, { key: "Enter" });
- expect(editor).toHaveTextContent("@Browser");
+ const chip = editor.querySelector("[data-mcp-name]");
+ expect(chip).not.toBeNull();
+ expect(chip).toHaveAttribute("data-mcp-id", "browser");
+ expect(chip).toHaveAttribute("data-mcp-name", "Browser");
+ // The badge still flattens to the `@Browser` directive the agent reads.
+ expect(ref.current?.serialize()).toBe("@Browser");
expect(onMcpMentionSelect).not.toHaveBeenCalled();
});
diff --git a/src/renderer/components/composer/MentionInput.tsx b/src/renderer/components/composer/MentionInput.tsx
index f7eda8f9..fb82c830 100644
--- a/src/renderer/components/composer/MentionInput.tsx
+++ b/src/renderer/components/composer/MentionInput.tsx
@@ -8,6 +8,7 @@ import type {
} from "@/shared/contracts";
import { fileNameFromPath, skillSegmentFromSlashCommand } from "@/shared/promptContent";
import { createChipElement, type FileMentionData } from "./FileMentionChip";
+import { createMcpMentionChipElement } from "./McpMentionChip";
import { createSlashCommandChipElement } from "./SlashCommandChip";
import { MentionPopover, type MentionEntry } from "./MentionPopover";
import { useDebouncedFileSearch } from "./useDebouncedFileSearch";
@@ -153,7 +154,7 @@ function detectTriggerRange(triggerChar: string): Range | null {
}
function hasEditorContent(editor: HTMLDivElement): boolean {
- if (editor.querySelector("[data-mention-path], [data-slash-command]")) return true;
+ if (editor.querySelector("[data-mention-path], [data-slash-command], [data-mcp-id]")) return true;
return (editor.textContent ?? "").trim().length > 0;
}
@@ -212,6 +213,8 @@ function appendPromptSegments(parent: Node, segments: PromptSegment[]): void {
parent.appendChild(
createSlashCommandChipElement({ id: segment.name, ...skillChipDataset(segment) }),
);
+ } else if (segment.kind === "mcp") {
+ parent.appendChild(createMcpMentionChipElement({ id: segment.id, name: segment.name }));
}
}
}
@@ -566,10 +569,14 @@ export const MentionInput = forwardRef<
range.deleteContents();
if (entry.enabled) {
- const mentionText = document.createTextNode(`@${entry.name} `);
- range.insertNode(mentionText);
+ const chip = createMcpMentionChipElement({ id: entry.path, name: entry.name });
+ range.insertNode(chip);
+ // Trailing nbsp keeps the caret visually separate from the chip, matching
+ // the file mention chip.
+ const space = document.createTextNode(" ");
+ chip.after(space);
const nextRange = document.createRange();
- nextRange.setStartAfter(mentionText);
+ nextRange.setStartAfter(space);
nextRange.collapse(true);
sel.removeAllRanges();
sel.addRange(nextRange);
@@ -666,7 +673,7 @@ export const MentionInput = forwardRef<
if (node.nodeType === Node.TEXT_NODE && offset === 0) {
const prev = node.previousSibling as HTMLElement | null;
- if (prev?.dataset?.mentionPath || prev?.dataset?.slashCommand) {
+ if (prev?.dataset?.mentionPath || prev?.dataset?.slashCommand || prev?.dataset?.mcpId) {
e.preventDefault();
prev.remove();
notifyTextChange();
@@ -676,7 +683,11 @@ export const MentionInput = forwardRef<
if (node.nodeType === Node.ELEMENT_NODE && offset > 0) {
const child = node.childNodes[offset - 1] as HTMLElement | undefined;
- if (child?.dataset?.mentionPath || child?.dataset?.slashCommand) {
+ if (
+ child?.dataset?.mentionPath ||
+ child?.dataset?.slashCommand ||
+ child?.dataset?.mcpId
+ ) {
e.preventDefault();
child.remove();
notifyTextChange();
diff --git a/src/renderer/components/composer/serializeMentions.test.ts b/src/renderer/components/composer/serializeMentions.test.ts
index ed9cd7c4..c822f234 100644
--- a/src/renderer/components/composer/serializeMentions.test.ts
+++ b/src/renderer/components/composer/serializeMentions.test.ts
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it } from "vitest";
import type { PromptSegment } from "@/shared/contracts";
+import { createMcpMentionChipElement } from "./McpMentionChip";
import { createSlashCommandChipElement } from "./SlashCommandChip";
import {
rebuildEditedPromptSegments,
@@ -66,6 +67,17 @@ describe("serializeComposerContent", () => {
expect(serializeComposerContent(container)).toBe("check @src/main.ts please");
});
+ it("serializes an MCP mention chip as an mcp segment that flattens to @name", () => {
+ container.appendChild(createMcpMentionChipElement({ id: "browser", name: "Browser" }));
+ container.appendChild(document.createTextNode(" open the page"));
+
+ expect(serializeToSegments(container)).toEqual([
+ { kind: "mcp", id: "browser", name: "Browser" },
+ { kind: "text", content: " open the page" },
+ ]);
+ expect(serializeComposerContent(container)).toBe("@Browser open the page");
+ });
+
it("serializes BR as newline", () => {
container.appendChild(document.createTextNode("line one"));
container.appendChild(document.createElement("br"));
diff --git a/src/renderer/components/composer/serializeMentions.ts b/src/renderer/components/composer/serializeMentions.ts
index ae52a492..0138c223 100644
--- a/src/renderer/components/composer/serializeMentions.ts
+++ b/src/renderer/components/composer/serializeMentions.ts
@@ -92,6 +92,12 @@ export function serializeToSegments(container: HTMLDivElement): PromptSegment[]
return;
}
+ if (el.dataset.mcpId && el.dataset.mcpName) {
+ flushText();
+ segments.push({ kind: "mcp", id: el.dataset.mcpId, name: el.dataset.mcpName });
+ return;
+ }
+
if (el.dataset.slashCommand) {
if (
el.dataset.skillName &&
diff --git a/src/renderer/components/find/chatFindMatches.ts b/src/renderer/components/find/chatFindMatches.ts
index c1ce8434..7ecb97be 100644
--- a/src/renderer/components/find/chatFindMatches.ts
+++ b/src/renderer/components/find/chatFindMatches.ts
@@ -25,7 +25,13 @@ function blocksToText(payload: unknown): string {
let out = "";
for (const block of blocks) {
if (!block || typeof block !== "object") continue;
- const record = block as { kind?: unknown; text?: unknown; path?: unknown; source?: unknown };
+ const record = block as {
+ kind?: unknown;
+ text?: unknown;
+ path?: unknown;
+ source?: unknown;
+ name?: unknown;
+ };
if (record.kind === "text" && typeof record.text === "string") {
out += record.text;
} else if (
@@ -34,6 +40,9 @@ function blocksToText(payload: unknown): string {
record.source !== "attachment"
) {
out += record.path;
+ } else if (record.kind === "mcp" && typeof record.name === "string") {
+ // Keep the badge's `@Name` directive findable in the transcript.
+ out += `@${record.name}`;
}
}
return out;
diff --git a/src/renderer/components/find/findMatching.test.ts b/src/renderer/components/find/findMatching.test.ts
index 9e48da27..a8f8c78b 100644
--- a/src/renderer/components/find/findMatching.test.ts
+++ b/src/renderer/components/find/findMatching.test.ts
@@ -46,6 +46,22 @@ describe("getChatItemSearchText", () => {
it("reads user text from payload content blocks", () => {
expect(getChatItemSearchText(user("u", "a prompt"))).toBe("a prompt");
});
+
+ it("makes an MCP mention badge findable by its @name directive", () => {
+ const item: RuntimeChatItem = {
+ id: "u",
+ type: "user_message",
+ state: "completed",
+ streams: {},
+ payload: {
+ content: [
+ { kind: "mcp", name: "Browser" },
+ { kind: "text", text: " open" },
+ ],
+ },
+ };
+ expect(getChatItemSearchText(item)).toBe("@Browser open");
+ });
});
describe("collectChatMatches", () => {
diff --git a/src/renderer/components/thread/ChatPane/ChatPane.test.tsx b/src/renderer/components/thread/ChatPane/ChatPane.test.tsx
index c082b932..37eaebbf 100644
--- a/src/renderer/components/thread/ChatPane/ChatPane.test.tsx
+++ b/src/renderer/components/thread/ChatPane/ChatPane.test.tsx
@@ -1129,6 +1129,20 @@ describe("ChatPane", () => {
expect(screen.queryByText("$simplify")).not.toBeInTheDocument();
});
+ it("renders MCP mentions in user messages as badges", async () => {
+ const thread = makeThread();
+ seedUserMessageContent(thread.id, [{ kind: "mcp", name: "Browser" }]);
+
+ const { container } = renderChatPane(thread);
+ await waitFor(() => expect(hydrateThreadRuntimeItems).toHaveBeenCalledWith(thread.id));
+
+ const badge = container.querySelector('[data-mcp-name="Browser"]');
+ expect(badge).toHaveTextContent("Browser");
+ expect(badge?.querySelector("svg")).toBeInTheDocument();
+ // The raw `@Browser` directive text is replaced by the badge.
+ expect(screen.queryByText("@Browser")).not.toBeInTheDocument();
+ });
+
it("copies user message text from the inline action", async () => {
const thread = makeThread();
seedUserMessage(thread.id, "Copy this prompt");
diff --git a/src/renderer/components/thread/ChatPane/parts/items/UserMessage.tsx b/src/renderer/components/thread/ChatPane/parts/items/UserMessage.tsx
index 6d724250..51dbc9ef 100644
--- a/src/renderer/components/thread/ChatPane/parts/items/UserMessage.tsx
+++ b/src/renderer/components/thread/ChatPane/parts/items/UserMessage.tsx
@@ -1,7 +1,7 @@
import { memo, type ReactNode, useEffectEvent, useLayoutEffect, useRef, useState } from "react";
import { Link, Surface, Tooltip } from "@heroui/react";
import { useLingui } from "@lingui/react/macro";
-import { ChevronDown, ChevronUp, Sparkles } from "lucide-react";
+import { ChevronDown, ChevronUp, Plug, Sparkles } from "lucide-react";
import type { CanonicalContentBlock, MessageItemPayload } from "@/shared/contracts";
import { AttachmentBar } from "@/renderer/components/composer/AttachmentBar";
import { openAttachmentLightbox } from "@/renderer/components/composer/ImageLightbox";
@@ -71,7 +71,10 @@ export const UserMessage = memo(function UserMessage({ item, checkpointRevert }:
const text = body;
const commandPrefixLength = slashCommand ? rawText.length - body.length : 0;
const hasInlineContent = content.some(
- (block) => block.kind === "skill" || (block.kind === "file" && block.source !== "attachment"),
+ (block) =>
+ block.kind === "skill" ||
+ block.kind === "mcp" ||
+ (block.kind === "file" && block.source !== "attachment"),
);
const attachments = enrichWithSelectorPayloads(
buildUserPromptAttachments(content),
@@ -295,6 +298,7 @@ function buildUserPromptText(content: CanonicalContentBlock[]): string {
.map((block) => {
if (block.kind === "text") return block.text;
if (block.kind === "skill") return block.invocation;
+ if (block.kind === "mcp") return `@${block.name}`;
if (block.kind === "file" && block.source !== "attachment") return block.path;
return "";
})
@@ -338,6 +342,21 @@ function renderUserMessageInlineContent(
return;
}
+ if (block.kind === "mcp") {
+ // An @-mention is never part of a leading /slash-command prefix, so —
+ // unlike the skill/file branches — an mcp block can never fall inside the
+ // skipped region and needs no remainingSkip handling.
+ nodes.push(
+ }
+ label={block.name}
+ mcpName={block.name}
+ />,
+ );
+ return;
+ }
+
if (block.kind === "file") {
if (block.source === "attachment") return;
if (remainingSkip >= block.path.length) {
@@ -365,15 +384,18 @@ function UserMessageSlashChip({
icon,
label,
skillName,
+ mcpName,
}: {
icon: ReactNode;
label: string;
skillName?: string;
+ mcpName?: string;
}) {
return (
{icon}
{label}
diff --git a/src/renderer/styles.css b/src/renderer/styles.css
index 695ed469..4a753ed1 100644
--- a/src/renderer/styles.css
+++ b/src/renderer/styles.css
@@ -2571,7 +2571,8 @@ html[data-app-unfocused] .poracode-provider-icon--finished {
cursor: default;
}
-.poracode-slash-chip[data-skill-name] {
+.poracode-slash-chip[data-skill-name],
+.poracode-slash-chip[data-mcp-name] {
gap: 0.25em;
}
diff --git a/src/shared/contracts/runtimeEvent.ts b/src/shared/contracts/runtimeEvent.ts
index 50a698ca..9e65a506 100644
--- a/src/shared/contracts/runtimeEvent.ts
+++ b/src/shared/contracts/runtimeEvent.ts
@@ -52,6 +52,7 @@ export type RuntimeContentStreamKind = z.infer;
diff --git a/src/shared/promptContent.test.ts b/src/shared/promptContent.test.ts
index 3621b316..6bd3d146 100644
--- a/src/shared/promptContent.test.ts
+++ b/src/shared/promptContent.test.ts
@@ -69,6 +69,18 @@ describe("buildPromptContentBlocks", () => {
},
]);
});
+
+ it("preserves MCP mention segments as mcp blocks for badge rendering", () => {
+ expect(
+ buildPromptContentBlocks("@Browser open the page", [
+ { kind: "mcp", id: "browser", name: "Browser" },
+ { kind: "text", content: " open the page" },
+ ]),
+ ).toEqual([
+ { kind: "mcp", name: "Browser" },
+ { kind: "text", text: " open the page" },
+ ]);
+ });
});
describe("toFileUrl", () => {
diff --git a/src/shared/promptContent.ts b/src/shared/promptContent.ts
index 8a193d2d..048b08f3 100644
--- a/src/shared/promptContent.ts
+++ b/src/shared/promptContent.ts
@@ -122,6 +122,8 @@ export function inlinePromptSegmentText(segment: PromptSegment): string {
return `@${segment.path}`;
case "skill":
return segment.invocation;
+ case "mcp":
+ return `@${segment.name}`;
case "text":
return segment.content;
}
@@ -171,6 +173,11 @@ export function buildPromptContentBlocks(
continue;
}
+ if (segment.kind === "mcp") {
+ content.push({ kind: "mcp", name: segment.name });
+ continue;
+ }
+
const name = fileNameFromPath(segment.path);
if (segment.kind === "attachment") {
if (isImagePath(segment.path, segment.mimeType)) {
diff --git a/src/supervisor/agents/claude/canonicalMapping/turn.ts b/src/supervisor/agents/claude/canonicalMapping/turn.ts
index 4567f27b..8e3383e1 100644
--- a/src/supervisor/agents/claude/canonicalMapping/turn.ts
+++ b/src/supervisor/agents/claude/canonicalMapping/turn.ts
@@ -24,6 +24,10 @@ export function buildPromptContentBlocks(
if (segment.content.length > 0) blocks.push({ kind: "text", text: segment.content });
continue;
}
+ if (segment.kind === "mcp") {
+ blocks.push({ kind: "mcp", name: segment.name });
+ continue;
+ }
blocks.push({
kind: "file",
path: segment.path,
diff --git a/src/supervisor/agents/claude/sdkPrompt.ts b/src/supervisor/agents/claude/sdkPrompt.ts
index f17b20ad..d8dc76a5 100644
--- a/src/supervisor/agents/claude/sdkPrompt.ts
+++ b/src/supervisor/agents/claude/sdkPrompt.ts
@@ -82,6 +82,11 @@ export async function buildSdkUserMessage(
});
continue;
}
+ if (segment.kind === "mcp") {
+ // MCP mentions are a plain-text directive for the turn, not a file ref.
+ textParts.push(`@${segment.name}`);
+ continue;
+ }
textParts.push(`@${segment.path}`);
}
flushText();
diff --git a/src/supervisor/agents/codex/acpTurn.ts b/src/supervisor/agents/codex/acpTurn.ts
index ba8b4e00..be550044 100644
--- a/src/supervisor/agents/codex/acpTurn.ts
+++ b/src/supervisor/agents/codex/acpTurn.ts
@@ -60,9 +60,19 @@ export function buildCodexTurnInput(
}
}
+ // When a skill segment is present the outgoing text is rebuilt from segments
+ // (skills/files/attachments already went into `input` structurally). MCP
+ // mentions have no Codex input type, so their `@Name` directive must ride
+ // along here as text — otherwise it would be silently dropped.
const text = hasSkillSegment
? (segments ?? [])
- .flatMap((segment) => (segment.kind === "text" ? [segment.content] : []))
+ .flatMap((segment) =>
+ segment.kind === "text"
+ ? [segment.content]
+ : segment.kind === "mcp"
+ ? [`@${segment.name}`]
+ : [],
+ )
.join("")
.trim()
: prompt;
diff --git a/src/supervisor/agents/codex/codex.test.ts b/src/supervisor/agents/codex/codex.test.ts
index abd72bf4..4995366e 100644
--- a/src/supervisor/agents/codex/codex.test.ts
+++ b/src/supervisor/agents/codex/codex.test.ts
@@ -1795,6 +1795,31 @@ describe("Codex skills", () => {
{ type: "text", text: "focus on security", text_elements: [] },
]);
});
+
+ it("keeps an MCP mention directive in the text when a skill segment is also present", () => {
+ expect(
+ buildCodexTurnInput("$review-code @Browser check the page", [
+ {
+ kind: "skill",
+ name: "review-code",
+ path: "/home/me/.agents/skills/review-code/SKILL.md",
+ invocation: "$review-code",
+ provider: "Codex",
+ scope: "global",
+ },
+ { kind: "text", content: " " },
+ { kind: "mcp", id: "browser", name: "Browser" },
+ { kind: "text", content: " check the page" },
+ ]),
+ ).toEqual([
+ {
+ type: "skill",
+ name: "review-code",
+ path: "/home/me/.agents/skills/review-code/SKILL.md",
+ },
+ { type: "text", text: "@Browser check the page", text_elements: [] },
+ ]);
+ });
});
describe("mapCodexRequirements", () => {
diff --git a/src/supervisor/agents/opencode/promptParts.ts b/src/supervisor/agents/opencode/promptParts.ts
index 6c1c8f64..94954482 100644
--- a/src/supervisor/agents/opencode/promptParts.ts
+++ b/src/supervisor/agents/opencode/promptParts.ts
@@ -200,6 +200,11 @@ export function buildOpenCodePromptParts(
if (segment.content.length > 0) parts.push({ type: "text", text: segment.content });
continue;
}
+ if (segment.kind === "mcp") {
+ // MCP mentions are a plain-text directive for the turn, not a file ref.
+ parts.push({ type: "text", text: `@${segment.name}` });
+ continue;
+ }
const absolute = resolveAbsolutePath(location, segment.path);
const url = fileUrlForPath(location, absolute);
const mime = mimeForSegment(segment, absolute);