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:
@@ -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);
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
27
src/ipc/deep_link_data.ts
Normal 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;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
|||||||
34
src/main.ts
34
src/main.ts
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user