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); setIsSuccessDialogOpen(true);
}; };
const { lastDeepLink } = useDeepLink(); const { lastDeepLink, clearLastDeepLink } = useDeepLink();
useEffect(() => { useEffect(() => {
const handleDeepLink = async () => { const handleDeepLink = async () => {
if (lastDeepLink?.type === "dyad-pro-return") { if (lastDeepLink?.type === "dyad-pro-return") {
await refreshSettings(); await refreshSettings();
showDyadProSuccessDialog(); showDyadProSuccessDialog();
clearLastDeepLink();
} }
}; };
handleDeepLink(); handleDeepLink();
}, [lastDeepLink]); }, [lastDeepLink?.timestamp]);
// Get selected app name // Get selected app name
const selectedApp = apps.find((app) => app.id === selectedAppId); const selectedApp = apps.find((app) => app.id === selectedAppId);

View File

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

View File

@@ -40,17 +40,18 @@ import { useTheme } from "@/contexts/ThemeContext";
export function SupabaseConnector({ appId }: { appId: number }) { export function SupabaseConnector({ appId }: { appId: number }) {
const { settings, refreshSettings } = useSettings(); const { settings, refreshSettings } = useSettings();
const { app, refreshApp } = useLoadApp(appId); const { app, refreshApp } = useLoadApp(appId);
const { lastDeepLink } = useDeepLink(); const { lastDeepLink, clearLastDeepLink } = useDeepLink();
const { isDarkMode } = useTheme(); const { isDarkMode } = useTheme();
useEffect(() => { useEffect(() => {
const handleDeepLink = async () => { const handleDeepLink = async () => {
if (lastDeepLink?.type === "supabase-oauth-return") { if (lastDeepLink?.type === "supabase-oauth-return") {
await refreshSettings(); await refreshSettings();
await refreshApp(); await refreshApp();
clearLastDeepLink();
} }
}; };
handleDeepLink(); handleDeepLink();
}, [lastDeepLink]); }, [lastDeepLink?.timestamp]);
const { const {
projects, projects,
loading, 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 { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -11,8 +11,10 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { useMcp, type Transport } from "@/hooks/useMcp"; 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 { 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 }; type KeyValue = { key: string; value: string };
@@ -299,6 +301,29 @@ export function ToolsMcpSettings() {
const [args, setArgs] = useState<string>(""); const [args, setArgs] = useState<string>("");
const [url, setUrl] = useState(""); const [url, setUrl] = useState("");
const [enabled, setEnabled] = useState(true); 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(() => { React.useEffect(() => {
setConsents(consentsMap); setConsents(consentsMap);

View File

@@ -1,31 +1,47 @@
import React, { createContext, useContext, useEffect, useState } from "react"; 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 = { type DeepLinkContextType = {
lastDeepLink: (DeepLinkData & { timestamp: number }) | null; lastDeepLink: (DeepLinkData & { timestamp: number }) | null;
clearLastDeepLink: () => void;
}; };
const DeepLinkContext = createContext<DeepLinkContextType>({ const DeepLinkContext = createContext<DeepLinkContextType>({
lastDeepLink: null, lastDeepLink: null,
clearLastDeepLink: () => {},
}); });
export function DeepLinkProvider({ children }: { children: React.ReactNode }) { export function DeepLinkProvider({ children }: { children: React.ReactNode }) {
const [lastDeepLink, setLastDeepLink] = useState< const [lastDeepLink, setLastDeepLink] = useState<
(DeepLinkData & { timestamp: number }) | null (DeepLinkData & { timestamp: number }) | null
>(null); >(null);
const scrollAndNavigateTo = useScrollAndNavigateTo("/settings", {
behavior: "smooth",
block: "start",
});
useEffect(() => { useEffect(() => {
const ipcClient = IpcClient.getInstance(); const ipcClient = IpcClient.getInstance();
const unsubscribe = ipcClient.onDeepLinkReceived((data) => { const unsubscribe = ipcClient.onDeepLinkReceived((data) => {
// Update with timestamp to ensure state change even if same type comes twice // Update with timestamp to ensure state change even if same type comes twice
setLastDeepLink({ ...data, timestamp: Date.now() }); setLastDeepLink({ ...data, timestamp: Date.now() });
if (data.type === "add-mcp-server") {
// Navigate to tools-mcp section
scrollAndNavigateTo("tools-mcp");
}
}); });
return unsubscribe; return unsubscribe;
}, []); }, []);
return ( return (
<DeepLinkContext.Provider value={{ lastDeepLink }}> <DeepLinkContext.Provider
value={{
lastDeepLink,
clearLastDeepLink: () => setLastDeepLink(null),
}}
>
{children} {children}
</DeepLinkContext.Provider> </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, ProposalResult,
} from "@/lib/schemas"; } from "@/lib/schemas";
import { showError } from "@/lib/toast"; import { showError } from "@/lib/toast";
import { DeepLinkData } from "./deep_link_data";
export interface ChatStreamCallbacks { export interface ChatStreamCallbacks {
onUpdate: (messages: Message[]) => void; onUpdate: (messages: Message[]) => void;
@@ -102,10 +103,6 @@ export interface GitHubDeviceFlowErrorData {
error: string; error: string;
} }
export interface DeepLinkData {
type: string;
}
interface DeleteCustomModelParams { interface DeleteCustomModelParams {
providerId: string; providerId: string;
modelApiName: string; modelApiName: string;

View File

@@ -18,6 +18,10 @@ import { BackupManager } from "./backup_manager";
import { getDatabasePath, initializeDatabase } from "./db"; import { getDatabasePath, initializeDatabase } from "./db";
import { UserSettings } from "./lib/schemas"; import { UserSettings } from "./lib/schemas";
import { handleNeonOAuthReturn } from "./neon_admin/neon_return_handler"; import { handleNeonOAuthReturn } from "./neon_admin/neon_return_handler";
import {
AddMcpServerConfigSchema,
AddMcpServerPayload,
} from "./ipc/deep_link_data";
log.errorHandler.startCatching(); log.errorHandler.startCatching();
log.eventLogger.startLogging(); log.eventLogger.startLogging();
@@ -323,6 +327,36 @@ function handleDeepLinkReturn(url: string) {
}); });
return; 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); dialog.showErrorBox("Invalid deep link URL", url);
} }