diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..d1a41cb --- /dev/null +++ b/PLAN.md @@ -0,0 +1,113 @@ +I'll scan the key files related to keybindings, the command palette, and MCP services to pinpoint tight coupling and propose concrete refactors. Pulling them up now. + +I reviewed the command palette, keybindings, and MCP pieces. The main tight couplings are: +- UI components directly mutate global atoms (via `getDefaultStore()`) and reach into other services (`gitChat`, MCP client) instead of using a clear boundary. +- Command palette mixes view concerns with command registry/state, and encodes hierarchy in label strings. +- MCP client both orchestrates connections and owns global state updates; server route also reaches directly into the same connection singleton. +- Types and utilities (e.g., `McpUrl`) are duplicated; URL/name derivation lives in the component. + +Here are targeted refactors to make it more loosely coupled and testable. + +### decouple state management from services +- Move Jotai writes out of service classes. Make services pure and event-driven. + - Define a minimal domain interface and event bus: +```ts +// services/mcp/ports.ts +export type McpConnectionEvent = + | { type: "connecting"; server: string } + | { type: "connected"; server: string; tools: Record } + | { type: "failed"; server: string; error: string } + | { type: "disconnected"; server: string }; + +export interface McpConnections { + updateConnections(urls: McpUrl[]): Promise; + reconnect(server: string): Promise; + on(listener: (e: McpConnectionEvent) => void): () => void; // unsubscribe +} +``` +- Implement the interface in `lib/mcp-connection-manager.ts` and emit events; remove any atom knowledge from `services/mcp/client.ts`. +- Add an adapter in `services/mcp/state-adapter.ts` that subscribes to events and updates `toolsAtom`, `breakdownAtom`, `connectionStatusAtom`, etc. UI then depends only on atoms, not on the service implementation. + +### introduce a command registry (DI) instead of hardcoding in `cmdk.tsx` +- Split a registry from the view: + - `services/commands/registry.ts` exposes `registerCommandSource(source)` and `useCommands()` that aggregates. + - A `CommandSource` provides an id, optional parent id, label, and an `execute()` or `children()` function. No hierarchy in label strings. +```ts +// services/commands/types.ts +export type Command = { + id: string; + title: string; + parentId?: string; + run?: () => void | Promise; +}; +export interface CommandSource { + id: string; + list(): Command[]; // pure, derived from its own store(s) +} +``` +- `components/cmdk.tsx` reads a derived atom `allCommandsAtom` that flattens across registered sources and renders a proper tree. Remove `HIERARCHY_SEPARATOR` and the `atomWithStorage` initialization that mixes static and dynamic content. Only persist user-defined custom commands separately if needed. +- Feature modules register themselves: + - MCP: provides “MCP Servers & Tools” + - Models: provides “Set Model” and children for each model + - Chat: provides “New Thread”, “Export Chat”, “Search Messages” populated from a chat store selector instead of capturing `gitChat` directly. + +### centralize keybinding suspension +- Replace repeated effects toggling `keybindingsActiveAtom` in dialogs/overlays with a hook: +```ts +// hooks/useSuspendKeybindings.ts +export function useSuspendKeybindings(suspend: boolean) { + const set = useSetAtom(keybindingsActiveAtom); + useEffect(() => { + if (suspend) set(false); + return () => set(true); + }, [suspend, set]); +} +``` +- Use in `CmdK` and `McpUrlManager` to avoid direct atom writes scattered across the codebase. Keep `components/keybinding.tsx` purely presentational and pass an explicit `isActive` if you want to remove the global entirely. + +### clarify MCP layering and remove duplication +- Single source of truth for `McpUrl` in `types/mcp.ts`. Import everywhere; remove duplicated interface declarations. +- Keep one connection manager singleton per runtime boundary: + - In the browser: `lib/mcp-connection-manager.ts` implements `McpConnections`. + - On the server route: either reuse the same interface with a separate instance scoped to the request/session, or accept tools from the client rather than reconnecting again in `app/api/chat/route.ts`. Right now the route re-calls `updateConnections`, which duplicates work and couples server to the same global manager. Pick one: + - Option A (client-sourced): Client passes `breakdown` and `tools` snapshot with the request; server uses it directly for `streamText`. + - Option B (server-owned): Server maintains its own manager instance keyed by user/session; client only sends URLs. Keep the browser manager independent and don’t import it server-side. +- Collapse `services/mcp/tools-service.ts` into a thin transformation layer: + - It should only convert manager output to UI format (serialization, failed-entry labeling), not call atoms. + - `services/mcp/client.ts` becomes an orchestrator that calls the manager and then emits domain events; the state adapter updates atoms. + +### remove label-encoded structure and side effects +- Stop deriving hierarchy from `command.name.split(HIERARCHY_SEPARATOR)`. Keep structure in data (`parentId` or explicit children) and render breadcrumbs from the tree. +- Eliminate `getDefaultStore()` mutations from outside React flows: + - For actions like “set model”, “open dialog”, expose explicit action functions in their own modules that update their stores. `cmdk` should invoke those actions, not poke atoms directly. + +### extract pure utilities and cover with tests +- From `components/mcp-url-manager.tsx`, move: + - `validateUrl` and `generateServerName` into `utils/mcp.ts`. + - `getCleanUrlForDisplay` into the same utils. +- Add simple unit tests for these to prevent regressions and to make the component only orchestrate UI. + +### improve loading/connection status modeling +- Replace the “connecting servers” Set plus a map in atoms with either: + - An `atomFamily` per server, or + - A single `Record` derived from the event stream. +- The manager should emit fine-grained per-server events; UI derives global summaries, avoiding ad-hoc state updates sprinkled across the client. + +### tighten server API boundary +- `app/api/chat/route.ts` should not be aware of connection lifecycle if the client already manages it. Either: + - Accept a `tools` payload from the client and pass it to `streamText`, or + - Own connections server-side and not import the browser singleton. Right now the import couples environments and can surprise in SSR. + +### small cleanups +- Co-locate atoms with their domain (commands/MCP/chat), but expose selectors instead of raw atoms to the UI where possible. +- Unify `Tool` serialization shape under `utils/tool-serialization.ts` and only serialize in UI adapters; keep core services dealing with raw `Tool`. + +Status update: +- I reviewed `components/keybinding.tsx`, `components/cmdk.tsx`, `components/mcp-url-manager.tsx`, `services/mcp/{client.ts,tools-service.ts}`, `lib/mcp-connection-manager.ts`, `utils/tool-serialization.ts`, and `app/api/chat/route.ts`. The proposals above target the specific coupling points I found. If you’d like, I can implement the minimal skeletons (ports, registry, keybinding hook) to make subsequent changes incremental. + +- Key changes to pursue: + - Define `McpConnections` interface and move Jotai writes to a state adapter. + - Add a command registry and remove label-encoded hierarchy from `cmdk`. + - Add `useSuspendKeybindings` and use it in dialogs. + - Deduplicate `McpUrl` type and move MCP utilities to `utils/mcp.ts`. + - Decide on server vs client ownership of MCP connections and adjust `app/api/chat/route.ts` accordingly. \ No newline at end of file diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 56b7f74..3a0a8fb 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -1,5 +1,6 @@ import { streamText } from "ai"; -import { mcpConnectionManager } from "@/lib/mcp-connection-manager"; +import { getToolsForUrls } from "@/lib/mcp-connection-core"; +import type { McpUrl } from "@/types/mcp"; import { resolveModel } from "../apiUtils"; export async function POST(req: Request) { @@ -8,12 +9,10 @@ export async function POST(req: Request) { console.log("Received pendingMessageConfig:", pendingMessageConfig); console.log("Received mcpUrls:", mcpUrls); - // Get tools from the connection manager (which maintains persistent connections) - const { tools, breakdown } = - await mcpConnectionManager.updateConnections(mcpUrls || []); + // Server-owned ephemeral utilities per request (stateless between requests) + const tools = await getToolsForUrls((mcpUrls || []) as McpUrl[]); console.log("TOOLS", tools); - console.log("BREAKDOWN", breakdown); const result = streamText({ model: resolveModel(pendingMessageConfig.modelName), diff --git a/components/mcp-url-manager.tsx b/components/mcp-url-manager.tsx index 852831f..4860057 100644 --- a/components/mcp-url-manager.tsx +++ b/components/mcp-url-manager.tsx @@ -27,10 +27,10 @@ import { toolsAtom, isMcpLoadingAtom, connectionStatusAtom, - type McpUrl, } from "@/services/mcp/atoms"; import { keybindingsActiveAtom } from "@/services/commands/atoms"; import mcpClient from "@/services/mcp/client"; +import { McpUrl } from "@/types/mcp"; function validateUrl(url: string): { valid: boolean; message?: string } { try { @@ -230,6 +230,7 @@ export function McpUrlManager() { id: crypto.randomUUID(), name: serverName, url: trimmedUrl, + callbackUrl: process.env.CALLBACK_URL, }; const updatedUrls = [...(urls || []), newUrlObj]; @@ -286,6 +287,7 @@ export function McpUrlManager() { id: crypto.randomUUID(), name: serverName, url: trimmedPastedText, + callbackUrl: process.env.CALLBACK_URL, }; const updatedUrls = [...(urls || []), newUrlObj]; diff --git a/lib/mcp-connection-core.ts b/lib/mcp-connection-core.ts new file mode 100644 index 0000000..af52045 --- /dev/null +++ b/lib/mcp-connection-core.ts @@ -0,0 +1,171 @@ +import { Tool } from "ai"; +import { experimental_createMCPClient as createMCPClient } from "ai"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import type { McpUrl } from "@/types/mcp"; + +interface ManagedConnection { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + client: any; // The MCPClient type is not exported from 'ai' package + tools: Record; + url: McpUrl; +} + +export class McpConnectionManager { + private connections: Map = new Map(); + private connectionPromises: Map> = + new Map(); + + getAllTools(): Record { + const allTools: Record = {}; + for (const connection of this.connections.values()) { + Object.assign(allTools, connection.tools); + } + return allTools; + } + + getBreakdown(): Record> { + const breakdown: Record> = {}; + for (const [serverName, connection] of this.connections.entries()) { + breakdown[serverName] = connection.tools; + } + return breakdown; + } + + async connectServer( + urlConfig: McpUrl + ): Promise<{ tools: Record; error?: string }> { + const existingPromise = this.connectionPromises.get(urlConfig.name); + if (existingPromise) { + const result = await existingPromise; + if (result) { + return { tools: result.tools }; + } else { + return { tools: {}, error: "Failed to connect" }; + } + } + + const existingConnection = this.connections.get(urlConfig.name); + if (existingConnection) { + return { tools: existingConnection.tools }; + } + + const connectionPromise = this.createConnection(urlConfig); + this.connectionPromises.set(urlConfig.name, connectionPromise); + + try { + const connection = await connectionPromise; + if (connection) { + this.connections.set(urlConfig.name, connection); + return { tools: connection.tools }; + } else { + return { tools: {}, error: "Failed to connect" }; + } + } finally { + this.connectionPromises.delete(urlConfig.name); + } + } + + async disconnectServer(serverName: string): Promise { + const connection = this.connections.get(serverName); + if (connection) { + try { + await connection.client.close(); + } catch (error) { + console.error(`Error closing connection to ${serverName}:`, error); + } + this.connections.delete(serverName); + } + } + + async updateConnections(newUrls: McpUrl[]): Promise<{ + tools: Record; + breakdown: Record>; + errors: Record; + }> { + const errors: Record = {}; + + const newServerNames = new Set(newUrls.map((url) => url.name)); + const currentServerNames = new Set(this.connections.keys()); + + const serversToAdd = newUrls.filter( + (url) => !currentServerNames.has(url.name) + ); + const serversToRemove = Array.from(currentServerNames).filter( + (name) => !newServerNames.has(name) + ); + + await Promise.all( + serversToRemove.map((name) => this.disconnectServer(name)) + ); + + await Promise.all( + serversToAdd.map(async (url) => { + try { + const result = await this.connectServer(url); + if (result.error) { + errors[url.name] = result.error; + } + } catch (error) { + console.error(`Failed to connect to ${url.name}:`, error); + errors[url.name] = + error instanceof Error ? error.message : "Connection failed"; + } + }) + ); + + return { + tools: this.getAllTools(), + breakdown: this.getBreakdown(), + errors, + }; + } + + async closeAll(): Promise { + await Promise.all( + Array.from(this.connections.keys()).map((name) => + this.disconnectServer(name) + ) + ); + } + + private async createConnection( + urlConfig: McpUrl + ): Promise { + try { + console.log("Connecting to MCP server:", urlConfig.name, urlConfig.url); + + const client = await createMCPClient({ + transport: new StreamableHTTPClientTransport(new URL(urlConfig.url)), + }); + + const tools = await client.tools(); + console.log( + `Successfully connected to ${urlConfig.name}, got ${ + Object.keys(tools).length + } tools` + ); + + return { + client, + tools, + url: urlConfig, + }; + } catch (error) { + console.error( + `Failed to connect to MCP server ${urlConfig.name}:`, + error + ); + return null; + } + } +} + +export async function getToolsForUrls( + urls: McpUrl[] +): Promise> { + const manager = new McpConnectionManager(); + const { tools } = await manager.updateConnections(urls); + // Clean up the ephemeral manager's connections after use + await manager.closeAll(); + return tools; +} diff --git a/lib/mcp-connection-manager.ts b/lib/mcp-connection-manager.ts index deb31f2..51bfaa4 100644 --- a/lib/mcp-connection-manager.ts +++ b/lib/mcp-connection-manager.ts @@ -1,198 +1,3 @@ -import { Tool } from "ai"; -import { experimental_createMCPClient as createMCPClient } from "ai"; -import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; - -export interface McpUrl { - id: string; - name: string; - url: string; -} - -interface ManagedConnection { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - client: any; // The MCPClient type is not exported from 'ai' package - tools: Record; - url: McpUrl; -} - -class MCPConnectionManager { - private connections: Map = new Map(); - private connectionPromises: Map> = - new Map(); - - /** - * Get all currently active tools from all connections - */ - getAllTools(): Record { - const allTools: Record = {}; - for (const connection of this.connections.values()) { - Object.assign(allTools, connection.tools); - } - return allTools; - } - - /** - * Get breakdown of tools by server - */ - getBreakdown(): Record> { - const breakdown: Record> = {}; - for (const [serverName, connection] of this.connections.entries()) { - breakdown[serverName] = connection.tools; - } - return breakdown; - } - - /** - * Connect to a specific server - */ - async connectServer( - urlConfig: McpUrl - ): Promise<{ tools: Record; error?: string }> { - // Check if already connecting - const existingPromise = this.connectionPromises.get(urlConfig.name); - if (existingPromise) { - const result = await existingPromise; - if (result) { - return { tools: result.tools }; - } else { - return { tools: {}, error: "Failed to connect" }; - } - } - - // Check if already connected - const existingConnection = this.connections.get(urlConfig.name); - if (existingConnection) { - return { tools: existingConnection.tools }; - } - - // Create connection promise - const connectionPromise = this.createConnection(urlConfig); - this.connectionPromises.set(urlConfig.name, connectionPromise); - - try { - const connection = await connectionPromise; - if (connection) { - this.connections.set(urlConfig.name, connection); - return { tools: connection.tools }; - } else { - return { tools: {}, error: "Failed to connect" }; - } - } finally { - this.connectionPromises.delete(urlConfig.name); - } - } - - /** - * Disconnect from a specific server - */ - async disconnectServer(serverName: string): Promise { - const connection = this.connections.get(serverName); - if (connection) { - try { - await connection.client.close(); - } catch (error) { - console.error(`Error closing connection to ${serverName}:`, error); - } - this.connections.delete(serverName); - } - } - - /** - * Update connections based on new URL list - * Only connects to new servers and disconnects removed ones - */ - async updateConnections(newUrls: McpUrl[]): Promise<{ - tools: Record; - breakdown: Record>; - errors: Record; - }> { - const errors: Record = {}; - - // Create sets for efficient lookups - const newServerNames = new Set(newUrls.map((url) => url.name)); - const currentServerNames = new Set(this.connections.keys()); - - // Find servers to add and remove - const serversToAdd = newUrls.filter( - (url) => !currentServerNames.has(url.name) - ); - const serversToRemove = Array.from(currentServerNames).filter( - (name) => !newServerNames.has(name) - ); - - // Remove servers that are no longer in the list - await Promise.all( - serversToRemove.map((name) => this.disconnectServer(name)) - ); - - // Add new servers - await Promise.all( - serversToAdd.map(async (url) => { - try { - const result = await this.connectServer(url); - if (result.error) { - errors[url.name] = result.error; - } - } catch (error) { - console.error(`Failed to connect to ${url.name}:`, error); - errors[url.name] = - error instanceof Error ? error.message : "Connection failed"; - } - }) - ); - - return { - tools: this.getAllTools(), - breakdown: this.getBreakdown(), - errors, - }; - } - - /** - * Close all connections - */ - async closeAll(): Promise { - await Promise.all( - Array.from(this.connections.keys()).map((name) => - this.disconnectServer(name) - ) - ); - } - - /** - * Create a connection to an MCP server - */ - private async createConnection( - urlConfig: McpUrl - ): Promise { - try { - console.log("Connecting to MCP server:", urlConfig.name, urlConfig.url); - - const client = await createMCPClient({ - transport: new StreamableHTTPClientTransport(new URL(urlConfig.url)), - }); - - const tools = await client.tools(); - console.log( - `Successfully connected to ${urlConfig.name}, got ${ - Object.keys(tools).length - } tools` - ); - - return { - client, - tools, - url: urlConfig, - }; - } catch (error) { - console.error( - `Failed to connect to MCP server ${urlConfig.name}:`, - error - ); - return null; - } - } -} - -// Global singleton instance -export const mcpConnectionManager = new MCPConnectionManager(); +// Re-export a browser-scoped singleton for use in the client runtime only. +import { McpConnectionManager } from "@/lib/mcp-connection-core"; +export const mcpConnectionManager = new McpConnectionManager(); diff --git a/lib/oauth-client.ts b/lib/oauth-client.ts new file mode 100644 index 0000000..027316f --- /dev/null +++ b/lib/oauth-client.ts @@ -0,0 +1,73 @@ +export interface OAuthClientOptions { + authorizationEndpoint: string; + tokenEndpoint: string; + clientId: string; + redirectUri?: string; + scope?: string; +} + +export class OAuthClient { + private options: OAuthClientOptions; + + constructor(options: OAuthClientOptions) { + this.options = { + ...options, + redirectUri: + options.redirectUri ?? (process.env.CALLBACK_URL as string | undefined), + }; + } + + public getAuthorizationUrl( + state?: string, + additionalParams?: Record + ): string { + const params = new URLSearchParams({ + response_type: "code", + client_id: this.options.clientId, + redirect_uri: this.options.redirectUri || "", + }); + + if (this.options.scope) params.set("scope", this.options.scope); + if (state) params.set("state", state); + if (additionalParams) { + for (const [k, v] of Object.entries(additionalParams)) { + if (v !== undefined && v !== null) params.set(k, String(v)); + } + } + + return `${this.options.authorizationEndpoint}?${params.toString()}`; + } + + public async exchangeCodeForToken( + code: string, + codeVerifier?: string + ): Promise { + const body = new URLSearchParams({ + grant_type: "authorization_code", + code, + client_id: this.options.clientId, + redirect_uri: this.options.redirectUri || "", + }); + if (codeVerifier) body.set("code_verifier", codeVerifier); + + const res = await fetch(this.options.tokenEndpoint, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString(), + }); + if (!res.ok) { + throw new Error(`Token exchange failed with status ${res.status}`); + } + return res.json(); + } + + public withAuthHeaders( + init: RequestInit = {}, + accessToken?: string + ): RequestInit { + const headers = new Headers(init.headers || {}); + if (accessToken) headers.set("Authorization", `Bearer ${accessToken}`); + return { ...init, headers }; + } +} + diff --git a/services/mcp/atoms.ts b/services/mcp/atoms.ts index 6a467a4..4826efe 100644 --- a/services/mcp/atoms.ts +++ b/services/mcp/atoms.ts @@ -1,20 +1,15 @@ import { Tool } from "ai"; import { atom } from "jotai"; import { atomWithStorage } from "jotai/utils"; - -export interface McpUrl { - id: string; - name: string; - url: string; -} +import type { McpUrl } from "@/types/mcp"; export interface McpConnectionStatus { [serverName: string]: "connected" | "failed" | "connecting"; } -export const toolsAtom = atom<{ breakdown: Record> } | null>( - null -); +export const toolsAtom = atom<{ + breakdown: Record>; +} | null>(null); export const isMcpLoadingAtom = atom(true); export const errorAtom = atom(null); export const reloadToolsAtom = atom(true); diff --git a/services/mcp/client.ts b/services/mcp/client.ts index 27f1f6b..8705396 100644 --- a/services/mcp/client.ts +++ b/services/mcp/client.ts @@ -11,9 +11,9 @@ import { activeServerNamesAtom, connectingServersAtom, type McpConnectionStatus, - type McpUrl, } from "./atoms"; -import { toolsService } from "./tools-service"; +import type { McpUrl } from "@/types/mcp"; +import { mcpConnectionManager } from "@/lib/mcp-connection-manager"; class MCPClient { private getToolsPromise: Promise | null = null; @@ -51,10 +51,6 @@ class MCPClient { return this.state.get(mcpUrlsAtom); } - private get activeServers() { - return this.state.get(activeServerNamesAtom); - } - public async getTools(specificServers?: string[]): Promise { // If there's already a getTools call in progress, return the existing promise if (this.getToolsPromise) { @@ -96,10 +92,14 @@ class MCPClient { this.setConnectionStatus(updatedStatus); this.setConnectingServers(targetServerSet); - // Use the tools service directly instead of API call - const { tools, breakdown } = await toolsService.getToolsWithBreakdown( - this.mcpUrls || [] - ); + // Ask connection manager to update connections and provide tools + const { breakdown, errors } = + await mcpConnectionManager.updateConnections(this.mcpUrls || []); + + // Add failed entries to breakdown for UI feedback + for (const [serverName] of Object.entries(errors)) { + breakdown[`${serverName} (Failed)`] = {}; + } // The toolsAtom expects the breakdown structure this.setTools({ breakdown }); diff --git a/services/mcp/tools-service.ts b/services/mcp/tools-service.ts index b890c10..be454ed 100644 --- a/services/mcp/tools-service.ts +++ b/services/mcp/tools-service.ts @@ -1,12 +1,9 @@ import { Tool } from "ai"; -import { serializeParameters, SerializedTool } from "@/utils/tool-serialization"; -import { mcpConnectionManager } from "@/lib/mcp-connection-manager"; - -export interface McpUrl { - id: string; - name: string; - url: string; -} +import { + serializeParameters, + SerializedTool, +} from "@/utils/tool-serialization"; +import type { McpUrl } from "@/types/mcp"; export class ToolsService { /** @@ -35,46 +32,9 @@ export class ToolsService { return serializedTools; } - /** - * Load tools from MCP servers - */ - async loadMcpTools(mcpUrls: McpUrl[]): Promise<{ - tools: Record; - breakdown: Record>; - errors: Record; - }> { - // Use the connection manager for incremental updates - const result = await mcpConnectionManager.updateConnections(mcpUrls); - - return { - tools: result.tools, - breakdown: result.breakdown, - errors: result.errors, - }; - } - - /** - * Get tools with breakdown (not serialized) - */ - async getToolsWithBreakdown(mcpUrls: McpUrl[] = []): Promise<{ - tools: Record; - breakdown: Record>; - errors: Record; - }> { - const { tools, breakdown, errors } = await this.loadMcpTools(mcpUrls); - - // Add failed entries to breakdown for UI feedback - for (const [serverName] of Object.entries(errors)) { - breakdown[`${serverName} (Failed)`] = {}; - } - - return { - tools, - breakdown, - errors, - }; - } + // This layer should only do UI-focused transformations. + // Orchestration is moved to the client and connection manager. } // Export singleton instance -export const toolsService = new ToolsService(); \ No newline at end of file +export const toolsService = new ToolsService(); diff --git a/types/mcp.ts b/types/mcp.ts new file mode 100644 index 0000000..f364733 --- /dev/null +++ b/types/mcp.ts @@ -0,0 +1,6 @@ +export interface McpUrl { + id: string; + name: string; + url: string; + callbackUrl?: string; +}