From 744e413e7195510a8571e1d53800a21df1522cfb Mon Sep 17 00:00:00 2001 From: Will Chen Date: Thu, 16 Oct 2025 10:20:16 -0700 Subject: [PATCH] Support deep linking MCP (#1550) > [!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`. > > 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). --- src/app/TitleBar.tsx | 5 +-- src/components/NeonConnector.tsx | 5 +-- src/components/SupabaseConnector.tsx | 5 +-- src/components/settings/ToolsMcpSettings.tsx | 29 +++++++++++++++-- src/contexts/DeepLinkContext.tsx | 22 +++++++++++-- src/ipc/deep_link_data.ts | 27 ++++++++++++++++ src/ipc/ipc_client.ts | 5 +-- src/main.ts | 34 ++++++++++++++++++++ 8 files changed, 117 insertions(+), 15 deletions(-) create mode 100644 src/ipc/deep_link_data.ts diff --git a/src/app/TitleBar.tsx b/src/app/TitleBar.tsx index a5a587e..889afb2 100644 --- a/src/app/TitleBar.tsx +++ b/src/app/TitleBar.tsx @@ -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); diff --git a/src/components/NeonConnector.tsx b/src/components/NeonConnector.tsx index 0ab6a84..2dc2de7 100644 --- a/src/components/NeonConnector.tsx +++ b/src/components/NeonConnector.tsx @@ -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 ( diff --git a/src/components/SupabaseConnector.tsx b/src/components/SupabaseConnector.tsx index ead7944..5ffcda9 100644 --- a/src/components/SupabaseConnector.tsx +++ b/src/components/SupabaseConnector.tsx @@ -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, diff --git a/src/components/settings/ToolsMcpSettings.tsx b/src/components/settings/ToolsMcpSettings.tsx index cf2f5a2..af1679a 100644 --- a/src/components/settings/ToolsMcpSettings.tsx +++ b/src/components/settings/ToolsMcpSettings.tsx @@ -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(""); 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); diff --git a/src/contexts/DeepLinkContext.tsx b/src/contexts/DeepLinkContext.tsx index 7bd6a17..9ed8895 100644 --- a/src/contexts/DeepLinkContext.tsx +++ b/src/contexts/DeepLinkContext.tsx @@ -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({ 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 ( - + setLastDeepLink(null), + }} + > {children} ); diff --git a/src/ipc/deep_link_data.ts b/src/ipc/deep_link_data.ts new file mode 100644 index 0000000..fe9e00d --- /dev/null +++ b/src/ipc/deep_link_data.ts @@ -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; +export type AddMcpServerPayload = { + name: string; + config: AddMcpServerConfig; +}; +export type AddMcpServerDeepLinkData = { + type: "add-mcp-server"; + payload: AddMcpServerPayload; +}; +export type DeepLinkData = + | AddMcpServerDeepLinkData + | { + type: string; + }; diff --git a/src/ipc/ipc_client.ts b/src/ipc/ipc_client.ts index 22aa775..ffc9aa0 100644 --- a/src/ipc/ipc_client.ts +++ b/src/ipc/ipc_client.ts @@ -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; diff --git a/src/main.ts b/src/main.ts index 999029a..bb5db55 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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); }