Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
aa15e79
πŸ€– feat: add agent-plugins experiment flag + plugin discovery module
ThomasK33 Aug 6, 2026
dc8a1db
πŸ€– feat: integrate Agent Plugins skills into skill discovery
ThomasK33 Aug 6, 2026
47ca361
πŸ€– feat: map Agent Plugins mcp.json into MCP server discovery
ThomasK33 Aug 6, 2026
4ddf5f3
πŸ€– docs: document the Agent Plugins experiment
ThomasK33 Aug 6, 2026
efd4a96
πŸ€– fix: address Codex review β€” off-host gating + worktree plugin disco…
ThomasK33 Aug 6, 2026
31762c4
πŸ€– fix: thread workspace Agent Plugins context through mcp.list/mcp.test
ThomasK33 Aug 6, 2026
7731cf3
πŸ€– fix: create nested PLUGIN_DATA cwd before plugin server launch
ThomasK33 Aug 6, 2026
1d89b54
πŸ€– fix: scan checkout root for plugin MCP + reject missing plugin-root…
ThomasK33 Aug 6, 2026
dcbfc06
πŸ€– fix: address Codex round 5 β€” workspace trust binding, checkout-root…
ThomasK33 Aug 6, 2026
e3ef82c
πŸ€– fix: resolve checkout-root plugin skills in skill-list tool and sla…
ThomasK33 Aug 6, 2026
ebb91d1
πŸ€– fix: reject directory paths as relative plugin commands
ThomasK33 Aug 6, 2026
a822257
πŸ€– fix: reject whitespace bare commands + keep contained plugin skill …
ThomasK33 Aug 6, 2026
043d65b
πŸ€– fix: stable symlinked global plugin identity + executable and NUL l…
ThomasK33 Aug 6, 2026
a77d3dc
πŸ€– fix: enforce checkout containment for project plugin skills in defa…
ThomasK33 Aug 6, 2026
3f516da
πŸ€– fix: seed workspace-scoped MCP test cache key in WorkspaceMCPModal …
ThomasK33 Aug 7, 2026
5ea5cb2
πŸ€– fix: show plugin install location so duplicate installations are di…
ThomasK33 Aug 7, 2026
6159691
πŸ€– fix: hide global tool-allowlist controls for plugin MCP servers
ThomasK33 Aug 7, 2026
05cc3d4
πŸ€– fix: wrap long plugin provenance in narrow MCP layouts
ThomasK33 Aug 7, 2026
f07e2d8
πŸ€– fix: self-heal corrupt plugin data paths by quarantining stray files
ThomasK33 Aug 7, 2026
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
9 changes: 9 additions & 0 deletions docs/agents/agent-skills.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,15 @@ Many repositories already ship skills in Claude Code's directories. Enable the *

Compat roots have the lowest precedence within their scope (`.mux/skills` > `.agents/skills` > `.claude/skills`, and likewise for the global roots), and they are read-only: Mux never writes or deletes skills in `.claude` directories. Skills that package Mux workflow scripts are not discovered from `.claude` roots.

### Agent Plugins (experiment)

Enable the **Agent Plugins** experiment (Settings β†’ Experiments) to also discover skills packaged in [Agent Plugins 1.0.0](https://agent-plugins.org) directories β€” a plugin is a directory with a `plugin.json` manifest plus an optional `skills/` folder. Plugins are discovered from:

- **Workspace-local**: `.mux/plugins/<plugin>/` and `.agents/plugins/<plugin>/`
- **Global**: `~/.mux/plugins/<plugin>/` and `~/.agents/plugins/<plugin>/`

Plugin skills have the lowest precedence within their scope and are read-only. A broken plugin (or a broken skill inside one) never affects other plugins or skills. Plugins can also ship MCP servers; see [MCP servers](/config/mcp-servers#agent-plugins-servers-experiment).

## Skill layout

A skill is a directory named after the skill:
Expand Down
12 changes: 12 additions & 0 deletions docs/config/mcp-servers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,18 @@ These overrides are stored in a workspace-local file: `.mux/mcp.local.jsonc`.

This means you configure servers once (globally or with repo overrides), but each workspace (branch) gets isolated server instances with independent state.

## Agent Plugins servers (experiment)

With the **Agent Plugins** experiment enabled (Settings β†’ Experiments), MCP servers declared in an [Agent Plugin's](https://agent-plugins.org) `mcp.json` also appear in server listings. Plugin servers are:

- **Disabled by default** β€” enable them per workspace via the Workspace MCP dialog; there is no global enable
- **Read-only** β€” they cannot be edited or removed, and are never written into `mcp.jsonc`
- **Trust-gated** β€” servers from a repo's `.mux/plugins` / `.agents/plugins` only appear once the project is trusted
- **Host-only** β€” plugin servers are skipped for SSH and devcontainer workspaces
- **Branch-following** β€” in a workspace, repo plugin servers load from the workspace's own checkout (matching skill discovery), while server identities stay stable across worktrees of the same project

Stdio plugin servers launch with the spec's `PLUGIN_ROOT` and `PLUGIN_DATA` environment variables; per-plugin data directories live under `~/.mux/plugin-data/`.

## Behavior

- **Hot reload** β€” Config changes apply on your next message (no restart needed)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@ const POSTHOG_TOOLS = [
"experiment-create",
];

const PROJECT_MCP_CACHE_KEY = getMCPTestResultsKey(PROJECT_PATH);
// The modal scopes its tool test cache by workspace (agent-plugins experiment:
// plugin server keys are stable across worktrees, but tool lists follow each
// workspace's checkout), so stories must seed the workspace-scoped key.
const PROJECT_MCP_CACHE_KEY = getMCPTestResultsKey(PROJECT_PATH, WORKSPACE_ID);

interface WorkspaceMCPStoryOptions {
servers?: Record<string, MCPServerInfo>;
Expand Down Expand Up @@ -393,3 +396,58 @@ export const ToggleServerEnabled: Story = {
});
},
};

/**
* Agent Plugin provenance with worst-case long names must not overflow the
* dialog horizontally β€” especially at the phone viewport, where an unbroken
* token would push the Fetch Tools button past the dialog edge. The play
* contract asserts no horizontal overflow at any viewport (the long fixture
* name overflows even the desktop max-w-2xl dialog without the min-w-0 fix);
* the Pixel phone variant guards the narrow rendering visually.
*/
export const WorkspaceMCPPluginServersNarrow: Story = {
globals: {
viewport: { value: "mobile1", isRotated: false },
},
parameters: {
...meta.parameters,
pixel: {
matrix: { themes: ["dark"], viewports: ["phone"] },
},
},
render: () =>
renderWorkspaceMCPModal({
servers: {
"plugin:abc123:everything": {
transport: "stdio",
command: "bunx",
disabled: true,
plugin: {
// No hyphens: hyphenated names wrap naturally, but the name grammar
// also allows unbroken alphanumeric/dot tokens, which only wrap when
// the layout allows shrinking (min-w-0) and breaking (break-words).
pluginName: "extremelylongpluginnamethatkeepsgoingandgoingforawhile0123456789",
serverName: "verylongmcpservernamewithmanytokensandthensome",
sourceScope: "project",
sourceLocation:
".agents/plugins/extremelylongpluginnamethatkeepsgoingandgoingforawhile0123456789",
},
},
mux: { transport: "stdio", command: "npx -y @anthropics/mux-server", disabled: false },
},
testResults: { mux: MOCK_TOOLS },
preCacheTools: true,
}),
play: async ({ canvasElement }) => {
const dialog = await findWorkspaceMCPDialog(canvasElement);
const modal = within(dialog);

await expect(
modal.findByText(/\.agents\/plugins\/extremelylongpluginname/)
).resolves.toBeInTheDocument();

// No horizontal overflow: long unbroken provenance must wrap/break inside
// the dialog instead of widening its scrollable content.
await expect(dialog.scrollWidth).toBeLessThanOrEqual(dialog.clientWidth);
},
};
43 changes: 32 additions & 11 deletions src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export const WorkspaceMCPModal: React.FC<WorkspaceMCPModalProps> = ({
const [error, setError] = useState<string | null>(null);

// Use shared cache for tool test results
const { getTools, setResult, reload: reloadCache } = useMCPTestCache(projectPath);
const { getTools, setResult, reload: reloadCache } = useMCPTestCache(projectPath, workspaceId);

// Ref so the effect can call reloadCache without depending on its identity.
// We only want to re-fire the effect when the modal opens (open/api/ids change),
Expand All @@ -60,7 +60,9 @@ export const WorkspaceMCPModal: React.FC<WorkspaceMCPModalProps> = ({
setError(null);
try {
const [projectServers, workspaceOverrides] = await Promise.all([
api.mcp.list({ projectPath }),
// workspaceId scopes Agent Plugins servers to this workspace's active
// checkout (and hides them entirely for off-host workspaces).
api.mcp.list({ projectPath, workspaceId }),
Comment thread
ThomasK33 marked this conversation as resolved.
api.workspace.mcp.get({ workspaceId }),
]);
setServers(projectServers ?? {});
Expand All @@ -81,7 +83,7 @@ export const WorkspaceMCPModal: React.FC<WorkspaceMCPModalProps> = ({
if (!api) return;
setLoadingTools((prev) => ({ ...prev, [serverName]: true }));
try {
const result = await api.mcp.test({ projectPath, name: serverName });
const result = await api.mcp.test({ projectPath, name: serverName, workspaceId });
setResult(serverName, result);
if (!result.success) {
setError(`Failed to fetch tools for ${serverName}: ${result.error}`);
Expand All @@ -92,7 +94,7 @@ export const WorkspaceMCPModal: React.FC<WorkspaceMCPModalProps> = ({
setLoadingTools((prev) => ({ ...prev, [serverName]: false }));
}
},
[api, projectPath, setResult]
[api, projectPath, workspaceId, setResult]
);

/**
Expand Down Expand Up @@ -319,6 +321,11 @@ export const WorkspaceMCPModal: React.FC<WorkspaceMCPModalProps> = ({
const tools = getTools(name);
const isLoadingTools = loadingTools[name];
const allowedTools = overrides.toolAllowlist?.[name] ?? tools ?? [];
// Agent Plugin servers keep the instance key as the override
// name but display readable provenance (plugin/server).
const displayName = info.plugin
? `${info.plugin.pluginName}/${info.plugin.serverName}`
: name;
Comment thread
ThomasK33 marked this conversation as resolved.

return (
<div
Expand All @@ -328,19 +335,33 @@ export const WorkspaceMCPModal: React.FC<WorkspaceMCPModalProps> = ({
!effectivelyEnabled && "opacity-50"
)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex items-center justify-between gap-2">
{/* min-w-0 chain: repo-controlled plugin/server names can be
long unbroken tokens; without it they push the Fetch
Tools button past the dialog edge at narrow widths. */}
<div className="flex min-w-0 items-center gap-3">
<Switch
checked={effectivelyEnabled}
onCheckedChange={(checked) =>
toggleServerEnabled(name, checked, projectDisabled)
}
aria-label={`Toggle ${name} MCP server`}
aria-label={`Toggle ${displayName} MCP server`}
/>
<div>
<div className="font-medium">{name}</div>
{projectDisabled && (
<div className="text-muted text-xs">(disabled at project level)</div>
<div className="min-w-0">
{/* wrap-anywhere (not truncate/break-words): repo-controlled
names must stay fully readable, and only overflow-wrap:
anywhere shrinks intrinsic min-content so the dialog's
grid track cannot be inflated by an unbroken token. */}
<div className="font-medium wrap-anywhere">{displayName}</div>
{info.plugin ? (
<div className="text-muted text-xs wrap-anywhere">
Agent Plugin ({info.plugin.sourceScope} Β· {info.plugin.sourceLocation}
) β€” disabled by default
</div>
) : (
projectDisabled && (
<div className="text-muted text-xs">(disabled at project level)</div>
)
)}
</div>
</div>
Expand Down
128 changes: 81 additions & 47 deletions src/browser/features/Settings/Sections/MCPSettingsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1163,6 +1163,12 @@ export const MCPSettingsSection: React.FC = () => {
const isEditing = editing?.name === name;
const isEnabled = !entry.disabled;
const remoteEntry = entry.transport === "stdio" ? null : entry;
// Agent Plugin servers are read-only config entries: no
// global enable/edit/remove; enable them per workspace.
const isPluginEntry = entry.plugin !== undefined;
const displayName = entry.plugin
? `${entry.plugin.pluginName}/${entry.plugin.serverName}`
: name;
return (
<div
key={name}
Expand All @@ -1174,20 +1180,38 @@ export const MCPSettingsSection: React.FC = () => {
<div className="mt-0.5 shrink-0">
<Switch
checked={isEnabled}
disabled={isPluginEntry}
onCheckedChange={(checked) =>
void handleToggleEnabled(name, checked)
}
aria-label={`Toggle ${name} enabled`}
aria-label={`Toggle ${displayName} enabled`}
/>
</div>
</TooltipTrigger>
<TooltipContent side="top">
{isEnabled ? "Disable server" : "Enable server"}
{isPluginEntry
? "Agent Plugin servers are enabled per workspace (Workspace MCP)"
: isEnabled
? "Disable server"
: "Enable server"}
</TooltipContent>
</Tooltip>
<div className={cn("min-w-0", !isEnabled && "opacity-50")}>
<div className="flex items-center gap-2">
<span className="text-foreground text-sm font-medium">{name}</span>
{/* flex-wrap + wrap-anywhere: repo-controlled plugin names and
install locations can be long unbroken tokens; wrap-anywhere
shrinks their min-content so they cannot starve the actions
column at ~375px. */}
<div className="flex flex-wrap items-center gap-2">
<span className="text-foreground min-w-0 text-sm font-medium wrap-anywhere">
{displayName}
</span>
{entry.plugin && (
<span className="text-muted bg-background min-w-0 rounded px-1.5 py-0.5 text-xs wrap-anywhere">
{/* Include the install location: same-name plugins can
exist in sibling containers (.mux vs .agents). */}
plugin Β· {entry.plugin.sourceLocation}
</span>
)}
{cached?.result.success && !isEditing && isEnabled && (
<Tooltip>
<TooltipTrigger asChild>
Expand Down Expand Up @@ -1311,39 +1335,44 @@ export const MCPSettingsSection: React.FC = () => {
</TooltipTrigger>
<TooltipContent side="top">Test connection</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<Button
variant="ghost"
size="icon"
onClick={() => handleStartEdit(name, entry)}
className="text-muted hover:text-accent h-7 w-7"
aria-label="Edit server"
>
<Pencil className="h-4 w-4" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent side="top">Edit server</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<Button
variant="ghost"
size="icon"
onClick={() => void handleRemove(name)}
disabled={loading}
className="text-muted hover:text-error h-7 w-7"
aria-label="Remove server"
>
<Trash2 className="h-4 w-4" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent side="top">Remove server</TooltipContent>
</Tooltip>
{/* Plugin entries are read-only: no edit/remove. */}
{!isPluginEntry && (
Comment thread
ThomasK33 marked this conversation as resolved.
<>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<Button
variant="ghost"
size="icon"
onClick={() => handleStartEdit(name, entry)}
className="text-muted hover:text-accent h-7 w-7"
aria-label="Edit server"
>
<Pencil className="h-4 w-4" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent side="top">Edit server</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<Button
variant="ghost"
size="icon"
onClick={() => void handleRemove(name)}
disabled={loading}
className="text-muted hover:text-error h-7 w-7"
aria-label="Remove server"
>
<Trash2 className="h-4 w-4" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent side="top">Remove server</TooltipContent>
</Tooltip>
</>
)}
</>
)}
</div>
Expand Down Expand Up @@ -1388,16 +1417,21 @@ export const MCPSettingsSection: React.FC = () => {
)}
</div>
)}
{cached?.result.success && cached.result.tools.length > 0 && !isEditing && (
<div className="border-border-medium border-t px-3 py-2">
<ToolAllowlistSection
serverName={name}
availableTools={cached.result.tools}
currentAllowlist={entry.toolAllowlist}
testedAt={cached.testedAt}
/>
</div>
)}
{/* Plugin servers are read-only here (setToolAllowlist rejects
plugin keys); their allowlists live in Workspace MCP. */}
{!isPluginEntry &&
cached?.result.success &&
cached.result.tools.length > 0 &&
!isEditing && (
<div className="border-border-medium border-t px-3 py-2">
<ToolAllowlistSection
serverName={name}
availableTools={cached.result.tools}
currentAllowlist={entry.toolAllowlist}
testedAt={cached.testedAt}
/>
</div>
)}
</div>
);
})
Expand Down
Loading
Loading