Support deep linking MCP (#1550)

<!-- CURSOR_SUMMARY -->
> [!NOTE]
> Adds support for `dyad://add-mcp-server` deep links that prefill MCP
server settings, and updates deep link context/consumers to use
timestamp-based effects and clearing to avoid repeat handling.
> 
> - **Deep Link Infrastructure**:
> - Introduce `src/ipc/deep_link_data.ts` with zod schema
(`AddMcpServerConfigSchema`) and typed `DeepLinkData`.
> - Extend `DeepLinkContext` with `clearLastDeepLink`, timestamped
events, and auto-navigate to `/settings#tools-mcp` on `add-mcp-server`.
> - **Main Process**:
>   - Handle `dyad://add-mcp-server?name=...&config=...`:
> - Base64-decode and validate `config`; send `deep-link-received` with
typed payload or show error.
> - **Settings UI (MCP)**:
> - In `ToolsMcpSettings`, prefill form from `add-mcp-server` payload
(supports `stdio` command/args and `http` url) and show info toast;
clear deep link after handling.
> - **Connectors/UI**:
>   - Update `TitleBar`, `NeonConnector`, `SupabaseConnector` to:
> - Depend on `lastDeepLink?.timestamp` and call `clearLastDeepLink()`
after handling (`dyad-pro-return`, `neon-oauth-return`,
`supabase-oauth-return`).
> - **IPC Renderer**:
>   - Use centralized `DeepLinkData` types in `ipc_client.ts`.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
294a9c6f38442241b54e9bcbe19a7a772d338ee0. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
This commit is contained in:
Will Chen
2025-10-16 10:20:16 -07:00
committed by GitHub
parent d76f428447
commit 744e413e71
8 changed files with 117 additions and 15 deletions

View File

@@ -49,16 +49,17 @@ export const TitleBar = () => {
setIsSuccessDialogOpen(true);
};
const { lastDeepLink } = useDeepLink();
const { lastDeepLink, clearLastDeepLink } = useDeepLink();
useEffect(() => {
const handleDeepLink = async () => {
if (lastDeepLink?.type === "dyad-pro-return") {
await refreshSettings();
showDyadProSuccessDialog();
clearLastDeepLink();
}
};
handleDeepLink();
}, [lastDeepLink]);
}, [lastDeepLink?.timestamp]);
// Get selected app name
const selectedApp = apps.find((app) => app.id === selectedAppId);

View File

@@ -11,7 +11,7 @@ import { NeonDisconnectButton } from "@/components/NeonDisconnectButton";
export function NeonConnector() {
const { settings, refreshSettings } = useSettings();
const { lastDeepLink } = useDeepLink();
const { lastDeepLink, clearLastDeepLink } = useDeepLink();
const { isDarkMode } = useTheme();
useEffect(() => {
@@ -19,10 +19,11 @@ export function NeonConnector() {
if (lastDeepLink?.type === "neon-oauth-return") {
await refreshSettings();
toast.success("Successfully connected to Neon!");
clearLastDeepLink();
}
};
handleDeepLink();
}, [lastDeepLink]);
}, [lastDeepLink?.timestamp]);
if (settings?.neon?.accessToken) {
return (

View File

@@ -40,17 +40,18 @@ import { useTheme } from "@/contexts/ThemeContext";
export function SupabaseConnector({ appId }: { appId: number }) {
const { settings, refreshSettings } = useSettings();
const { app, refreshApp } = useLoadApp(appId);
const { lastDeepLink } = useDeepLink();
const { lastDeepLink, clearLastDeepLink } = useDeepLink();
const { isDarkMode } = useTheme();
useEffect(() => {
const handleDeepLink = async () => {
if (lastDeepLink?.type === "supabase-oauth-return") {
await refreshSettings();
await refreshApp();
clearLastDeepLink();
}
};
handleDeepLink();
}, [lastDeepLink]);
}, [lastDeepLink?.timestamp]);
const {
projects,
loading,

View File

@@ -1,4 +1,4 @@
import React, { useMemo, useState } from "react";
import React, { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -11,8 +11,10 @@ import {
SelectValue,
} from "@/components/ui/select";
import { useMcp, type Transport } from "@/hooks/useMcp";
import { showError, showSuccess } from "@/lib/toast";
import { showError, showInfo, showSuccess } from "@/lib/toast";
import { Edit2, Plus, Save, Trash2, X } from "lucide-react";
import { useDeepLink } from "@/contexts/DeepLinkContext";
import { AddMcpServerDeepLinkData } from "@/ipc/deep_link_data";
type KeyValue = { key: string; value: string };
@@ -299,6 +301,29 @@ export function ToolsMcpSettings() {
const [args, setArgs] = useState<string>("");
const [url, setUrl] = useState("");
const [enabled, setEnabled] = useState(true);
const { lastDeepLink, clearLastDeepLink } = useDeepLink();
console.log("lastDeepLink!!!", lastDeepLink);
useEffect(() => {
console.log("rerun effect");
const handleDeepLink = async () => {
if (lastDeepLink?.type === "add-mcp-server") {
const deepLink = lastDeepLink as AddMcpServerDeepLinkData;
const payload = deepLink.payload;
showInfo(`Prefilled ${payload.name} MCP server`);
setName(payload.name);
setTransport(payload.config.type);
if (payload.config.type === "stdio") {
const [command, ...args] = payload.config.command.split(" ");
setCommand(command);
setArgs(args.join(" "));
} else {
setUrl(payload.config.url);
}
clearLastDeepLink();
}
};
handleDeepLink();
}, [lastDeepLink?.timestamp]);
React.useEffect(() => {
setConsents(consentsMap);

View File

@@ -1,31 +1,47 @@
import React, { createContext, useContext, useEffect, useState } from "react";
import { IpcClient, DeepLinkData } from "../ipc/ipc_client";
import { IpcClient } from "../ipc/ipc_client";
import { DeepLinkData } from "../ipc/deep_link_data";
import { useScrollAndNavigateTo } from "@/hooks/useScrollAndNavigateTo";
type DeepLinkContextType = {
lastDeepLink: (DeepLinkData & { timestamp: number }) | null;
clearLastDeepLink: () => void;
};
const DeepLinkContext = createContext<DeepLinkContextType>({
lastDeepLink: null,
clearLastDeepLink: () => {},
});
export function DeepLinkProvider({ children }: { children: React.ReactNode }) {
const [lastDeepLink, setLastDeepLink] = useState<
(DeepLinkData & { timestamp: number }) | null
>(null);
const scrollAndNavigateTo = useScrollAndNavigateTo("/settings", {
behavior: "smooth",
block: "start",
});
useEffect(() => {
const ipcClient = IpcClient.getInstance();
const unsubscribe = ipcClient.onDeepLinkReceived((data) => {
// Update with timestamp to ensure state change even if same type comes twice
setLastDeepLink({ ...data, timestamp: Date.now() });
if (data.type === "add-mcp-server") {
// Navigate to tools-mcp section
scrollAndNavigateTo("tools-mcp");
}
});
return unsubscribe;
}, []);
return (
<DeepLinkContext.Provider value={{ lastDeepLink }}>
<DeepLinkContext.Provider
value={{
lastDeepLink,
clearLastDeepLink: () => setLastDeepLink(null),
}}
>
{children}
</DeepLinkContext.Provider>
);

27
src/ipc/deep_link_data.ts Normal file
View File

@@ -0,0 +1,27 @@
import { z } from "zod";
export const AddMcpServerConfigSchema = z.discriminatedUnion("type", [
z.object({
type: z.enum(["stdio"]),
command: z.string(),
}),
z.object({
type: z.enum(["http"]),
url: z.string(),
}),
]);
export type AddMcpServerConfig = z.infer<typeof AddMcpServerConfigSchema>;
export type AddMcpServerPayload = {
name: string;
config: AddMcpServerConfig;
};
export type AddMcpServerDeepLinkData = {
type: "add-mcp-server";
payload: AddMcpServerPayload;
};
export type DeepLinkData =
| AddMcpServerDeepLinkData
| {
type: string;
};

View File

@@ -77,6 +77,7 @@ import type {
ProposalResult,
} from "@/lib/schemas";
import { showError } from "@/lib/toast";
import { DeepLinkData } from "./deep_link_data";
export interface ChatStreamCallbacks {
onUpdate: (messages: Message[]) => void;
@@ -102,10 +103,6 @@ export interface GitHubDeviceFlowErrorData {
error: string;
}
export interface DeepLinkData {
type: string;
}
interface DeleteCustomModelParams {
providerId: string;
modelApiName: string;

View File

@@ -18,6 +18,10 @@ import { BackupManager } from "./backup_manager";
import { getDatabasePath, initializeDatabase } from "./db";
import { UserSettings } from "./lib/schemas";
import { handleNeonOAuthReturn } from "./neon_admin/neon_return_handler";
import {
AddMcpServerConfigSchema,
AddMcpServerPayload,
} from "./ipc/deep_link_data";
log.errorHandler.startCatching();
log.eventLogger.startLogging();
@@ -323,6 +327,36 @@ function handleDeepLinkReturn(url: string) {
});
return;
}
// dyad://add-mcp-server?name=Chrome%20DevTools&config=eyJjb21tYW5kIjpudWxsLCJ0eXBlIjoic3RkaW8ifQ%3D%3D
if (parsed.hostname === "add-mcp-server") {
const name = parsed.searchParams.get("name");
const config = parsed.searchParams.get("config");
if (!name || !config) {
dialog.showErrorBox("Invalid URL", "Expected name and config");
return;
}
try {
const decodedConfigJson = atob(config);
const decodedConfig = JSON.parse(decodedConfigJson);
const parsedConfig = AddMcpServerConfigSchema.parse(decodedConfig);
mainWindow?.webContents.send("deep-link-received", {
type: parsed.hostname,
payload: {
name,
config: parsedConfig,
} as AddMcpServerPayload,
});
} catch (error) {
logger.error("Failed to parse add-mcp-server deep link:", error);
dialog.showErrorBox(
"Invalid MCP Server Configuration",
"The deep link contains malformed configuration data. Please check the URL and try again.",
);
}
return;
}
dialog.showErrorBox("Invalid deep link URL", url);
}