Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 3 additions & 2 deletions app/api/control/chat/_lib/connection-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,15 @@ import {
} from "@/lib/team-capabilities";
import type { Connection } from "@/lib/types";
import type { Tool } from "ai";
import { CONNECTION_TOOL_STARTUP_TIMEOUT_MS } from "@/lib/connections/mcp-tools";

/**
* How long a Control turn waits for connection tools. Remote MCP servers are
* someone else's uptime: a slow one must cost the turn its connection tools,
* never the turn itself. Same posture as the memory block: fail open.
*/
export const CONTROL_CONNECTION_TOOLS_TIMEOUT_MS = 8000;
export const CONTROL_CONNECTION_TOOLS_TIMEOUT_MS =
CONNECTION_TOOL_STARTUP_TIMEOUT_MS;

export type ControlConnectionTools = {
tools: Record<string, Tool>;
Expand Down Expand Up @@ -99,7 +101,6 @@ export async function loadControlConnectionTools(
}

const connections = await deps.loadConnections(input.userId, repoId);
if (connections.length === 0) return NO_CONNECTION_TOOLS;

const building = deps.buildTools(connections, {
userId: input.userId,
Expand Down
4 changes: 2 additions & 2 deletions components/settings/mcp-servers-page-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,8 @@ export function McpServersPageClient() {
<div>
<div className="ui-section-title">Server Definitions</div>
<div className="ui-section-caption">
Manage MCP servers that sync with the Mogplex CLI. Saved secrets
stay hidden in the browser.
Enabled public HTTP servers provide tools in web chat and the CLI.
Local servers run through the CLI. Saved secrets stay hidden in the browser.
</div>
</div>
<div className="flex flex-wrap gap-2">
Expand Down
18 changes: 12 additions & 6 deletions components/settings/mcp-servers/dialogs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export function McpServerDialog({
const dialogTitle = editingServer ? "Edit MCP server" : "Add MCP server";
const dialogDescription = editingServer
? "Update the synced server definition. Saved secrets stay masked until you overwrite or clear them."
: "Create a synced MCP server definition for CLI and agent sessions.";
: "Add a server for web chat and the CLI.";

return (
<Dialog open={open} onOpenChange={onClose}>
Expand Down Expand Up @@ -86,6 +86,7 @@ export function McpServerDialog({

<div className="flex items-center gap-3 rounded-lg border border-border bg-background/60 px-3 py-2">
<Switch
aria-label="Server enabled"
checked={form.enabled}
onCheckedChange={(checked) =>
onFormChange((current) => ({ ...current, enabled: checked }))
Expand All @@ -94,8 +95,7 @@ export function McpServerDialog({
<div className="space-y-0.5">
<div className="text-sm text-foreground">Enabled</div>
<div className="text-[11px] text-muted-foreground">
Disabled servers stay stored but do not sync into the CLI
cache.
Disabled servers stay saved but cannot provide tools.
</div>
</div>
</div>
Expand All @@ -112,11 +112,14 @@ export function McpServerDialog({
className="space-y-4"
>
<TabsList className="h-9">
<TabsTrigger value="stdio">stdio</TabsTrigger>
<TabsTrigger value="http">http</TabsTrigger>
<TabsTrigger value="http">Streamable HTTP</TabsTrigger>
<TabsTrigger value="stdio">Local (CLI only)</TabsTrigger>
</TabsList>

<TabsContent value="stdio" className="space-y-4">
<p className="text-sm text-muted-foreground">
Runs on your computer through the CLI. Not available in web chat.
</p>
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<div className="space-y-2">
<Label htmlFor="mcp-server-command">Command</Label>
Expand Down Expand Up @@ -161,6 +164,9 @@ export function McpServerDialog({
</TabsContent>

<TabsContent value="http" className="space-y-4">
<p className="text-sm text-muted-foreground">
Available in web chat and the CLI. Web chat requires a public server URL.
</p>
<div className="space-y-2">
<Label htmlFor="mcp-server-url">URL</Label>
<Input
Expand All @@ -172,7 +178,7 @@ export function McpServerDialog({
url: event.target.value,
}))
}
placeholder="https://mcp.linear.app/sse"
placeholder="https://mcp.linear.app/mcp"
/>
</div>

Expand Down
1 change: 1 addition & 0 deletions components/settings/mcp-servers/server-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export function ServerCard({ server, onEdit, onDelete }: ServerCardProps) {
<Badge variant="outline">
{server.enabled ? "Enabled" : "Disabled"}
</Badge>
{server.transport === "stdio" && <Badge variant="outline">CLI only</Badge>}
</div>
<p className="text-[11px] leading-5 text-muted-foreground">
{summarizeServer(server)}
Expand Down
2 changes: 1 addition & 1 deletion components/settings/mcp-servers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export type FormState = {
export const EMPTY_FORM: FormState = {
name: "",
enabled: true,
transport: "stdio",
transport: "http",
command: "",
argsText: "",
url: "",
Expand Down
39 changes: 26 additions & 13 deletions lib/agents/tools/connections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { logConnectionEvent } from "@/lib/connections/logging";
import { hasCapability, type Capability } from "@/lib/team-capabilities";
import type { Connection } from "@/lib/types";
import { sanitize } from "./shared";
import { loadSavedMcpServerTools } from "@/lib/mcp-servers/chat";

export const DYNAMIC_CONNECTION_CAPABILITY: Capability = "connections.create";

Expand Down Expand Up @@ -141,25 +142,37 @@ export async function buildDynamicConnectionTools(
return false;
});

const savedLoading = ctx.userId
Comment thread
charlesrhoward marked this conversation as resolved.
? loadSavedMcpServerTools(ctx.userId, ctx.canAskApproval)
: null;
const results = await Promise.allSettled(
runnable.map((conn) =>
loadConnectionTools(conn, ctx, getValidAccessToken, deps.getCredentials)
)
);

return {
...registerLoadedTools(
results.map((result, index) =>
result.status === "fulfilled" && result.value
? {
loaded: result.value,
asks: runnable[index].approval_mode === "ask",
}
: null
)
),
withheldConnections,
};
const registered = registerLoadedTools(
results.map((result, index) =>
result.status === "fulfilled" && result.value
? {
loaded: result.value,
asks: runnable[index].approval_mode === "ask",
}
: null
)
);
if (savedLoading) {
const saved = await savedLoading;
registered.mcpCleanups.push(...saved.mcpCleanups);
for (const [name, tool] of Object.entries(saved.dynamicTools)) {
// Preserve the permissions and identity of existing integration tools.
if (name in registered.dynamicTools) continue;
registered.dynamicTools[name] = tool;
registered.mcpToolNames.add(name);
if (saved.askToolNames.has(name)) registered.askToolNames.add(name);
}
}
return { ...registered, withheldConnections };
}

/** Name each loaded tool and sort the names into the sets callers act on. */
Expand Down
8 changes: 0 additions & 8 deletions lib/agents/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -447,14 +447,6 @@ export async function buildTools(opts: {
}

const connections = await loadScopedConnections(opts.userId, opts.repoId);
if (connections.length === 0) {
recordDeniedTools(opts.teamId ?? null, opts.userId, deniedTools);
return {
tools: withIdempotency(staticTools),
connections: [],
cleanup: emptyCleanup,
};
}

const { dynamicTools, mcpCleanups, mcpToolNames, restToolNames } =
await buildDynamicConnectionTools(connections, {
Expand Down
42 changes: 38 additions & 4 deletions lib/connections/mcp-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,51 @@ export type McpToolsResult = {
cleanup: () => Promise<void>;
};

// The existing Control startup budget also bounds saved-server discovery.
export const CONNECTION_TOOL_STARTUP_TIMEOUT_MS = 8000;

export async function getMcpTools(
conn: Connection,
credential?: string
): Promise<McpToolsResult> {
if (conn.mcp_url) {
await assertSafeOutboundHttpUrlWithDns(conn.mcp_url, "mcp_url");
}
return getRemoteMcpTools(buildMcpTransport(conn, credential));
}

export async function getRemoteMcpTools(
transport: ReturnType<typeof buildMcpTransport>,
options: { validateRequests?: boolean; startupSignal?: AbortSignal } = {}
): Promise<McpToolsResult> {
await assertSafeOutboundHttpUrlWithDns(transport.url, "mcp_url");
options.startupSignal?.throwIfAborted();
let starting = true;

const client = await createMCPClient({
transport: buildMcpTransport(conn, credential),
initializationOptions: { signal: options.startupSignal },
transport: {
...transport,
// Saved servers use guarded requests. Keep existing Integrations'
// transport behavior unchanged.
fetch: options.validateRequests
? async (input, init) => {
const url = input instanceof Request ? input.url : String(input);
await assertSafeOutboundHttpUrlWithDns(url, "mcp_url");
const signal =
starting && options.startupSignal
? AbortSignal.any([
options.startupSignal,
...(init?.signal ? [init.signal] : []),
])
: init?.signal;
signal?.throwIfAborted();
return fetch(input, { ...init, signal, redirect: "error" });
}
: undefined,
},
});

try {
const tools = await client.tools();
options.startupSignal?.throwIfAborted();
let closed = false;
return {
tools,
Expand All @@ -35,6 +66,9 @@ export async function getMcpTools(
} catch (error) {
await client.close().catch(() => undefined);
throw error;
} finally {
// A startup deadline must never abort a later tool call.
starting = false;
}
}

Expand Down
Loading
Loading