diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c94d0cdb..704540203 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Added + +- Add reusable remote MCP connections for ACP agents, with Streamable HTTP endpoints and bearer tokens stored in the configured credential backend. + ## 0.14.0 - 2026-08-10 ### Breaking changes diff --git a/packages/docs/programmable/ai-chat.md b/packages/docs/programmable/ai-chat.md index 7bc050b4b..532afcc3e 100644 --- a/packages/docs/programmable/ai-chat.md +++ b/packages/docs/programmable/ai-chat.md @@ -31,6 +31,12 @@ You can configure multiple reusable models and separately assign models for desi No backend, no subscription — your key talks directly to the provider. Browser requests are subject to each provider's CORS policy, and model deployments vary in how reliably they stream tool calls. See [BYOK provider and model compatibility](./byok-provider-compatibility) for measured results and reproduction steps. +### External MCP connections + +Desktop ACP agents can also use trusted remote [Model Context Protocol](https://modelcontextprotocol.io/) servers. In **Settings → AI & agents → MCP connections**, add a named Streamable HTTP endpoint, optionally save a bearer token, and enable the connection. OpenPencil stores the token in the configured credential backend rather than ordinary settings and resolves it only when starting the ACP session. + +Remote servers must use HTTPS. Loopback HTTP endpoints are accepted for local development. Review and trust a server before enabling it: its tools may read external data or perform actions with the credentials you provide. OpenPencil's built-in design MCP server remains attached automatically and does not need to be added here. + ## What It Can Do The assistant has 90+ tools across these categories: diff --git a/packages/vue/src/i18n/locales/de/dialogs.json b/packages/vue/src/i18n/locales/de/dialogs.json index be621a723..bbdadc024 100644 --- a/packages/vue/src/i18n/locales/de/dialogs.json +++ b/packages/vue/src/i18n/locales/de/dialogs.json @@ -87,6 +87,22 @@ "settingsAIAndAgents": "KI und Agenten", "models": "Modelle", "modelsDescription": "Wiederverwendbare Modelle und ihre Anbieter-Verbindungen konfigurieren.", + "mcpConnections": "MCP-Verbindungen", + "mcpConnectionsDescription": "Gewähre ACP-Agenten Zugriff auf vertrauenswürdige entfernte Tools und Dienste.", + "addConnection": "Verbindung hinzufügen", + "addMCPConnection": "MCP-Verbindung hinzufügen", + "editMCPConnection": "MCP-Verbindung bearbeiten", + "mcpConnectionEditorDescription": "Konfiguriere einen Streamable-HTTP-Server und optionale Authentifizierung.", + "connectionName": "Verbindungsname", + "mcpServerURL": "MCP-Server-URL", + "enableMCPConnection": "Für ACP-Agenten aktivieren", + "mcpBearerAuthentication": "Bearer-Authentifizierung verwenden", + "mcpBearerToken": "Bearer-Token", + "mcpBearerTokenPlaceholder": "Bearer-Token eingeben", + "mcpBearerTokenRequired": "Gib ein Bearer-Token ein, bevor du diese Verbindung aktivierst.", + "deleteMCPConnection": "Verbindung löschen", + "deleteMCPConnectionDescription": "Diese MCP-Verbindung und ihr gespeichertes Bearer-Token löschen?", + "noMCPConnections": "Keine externen MCP-Verbindungen konfiguriert.", "addModel": "Modell hinzufügen", "editModel": "Modell bearbeiten", "modelEditorDescription": "Anbieter, Modell, Zugangsdaten und Fähigkeiten.", diff --git a/packages/vue/src/i18n/locales/es/dialogs.json b/packages/vue/src/i18n/locales/es/dialogs.json index f7cba0923..9e239a646 100644 --- a/packages/vue/src/i18n/locales/es/dialogs.json +++ b/packages/vue/src/i18n/locales/es/dialogs.json @@ -87,6 +87,22 @@ "settingsAIAndAgents": "IA y agentes", "models": "Modelos", "modelsDescription": "Configura modelos reutilizables y sus conexiones de proveedor.", + "mcpConnections": "Conexiones MCP", + "mcpConnectionsDescription": "Da acceso a los agentes ACP a herramientas y servicios remotos de confianza.", + "addConnection": "Añadir conexión", + "addMCPConnection": "Añadir conexión MCP", + "editMCPConnection": "Editar conexión MCP", + "mcpConnectionEditorDescription": "Configura un servidor Streamable HTTP y autenticación opcional.", + "connectionName": "Nombre de la conexión", + "mcpServerURL": "URL del servidor MCP", + "enableMCPConnection": "Activar para agentes ACP", + "mcpBearerAuthentication": "Usar autenticación Bearer", + "mcpBearerToken": "Token Bearer", + "mcpBearerTokenPlaceholder": "Introduce el token Bearer", + "mcpBearerTokenRequired": "Introduce un token Bearer antes de activar esta conexión.", + "deleteMCPConnection": "Eliminar conexión", + "deleteMCPConnectionDescription": "¿Eliminar esta conexión MCP y su token Bearer guardado?", + "noMCPConnections": "No hay conexiones MCP externas configuradas.", "addModel": "Añadir modelo", "editModel": "Editar modelo", "modelEditorDescription": "Proveedor, modelo, credenciales y capacidades.", diff --git a/packages/vue/src/i18n/locales/fr/dialogs.json b/packages/vue/src/i18n/locales/fr/dialogs.json index 13c0c7010..26bbb9cb0 100644 --- a/packages/vue/src/i18n/locales/fr/dialogs.json +++ b/packages/vue/src/i18n/locales/fr/dialogs.json @@ -87,6 +87,22 @@ "settingsAIAndAgents": "IA et agents", "models": "Modèles", "modelsDescription": "Configurez des modèles réutilisables et leurs connexions fournisseur.", + "mcpConnections": "Connexions MCP", + "mcpConnectionsDescription": "Donnez aux agents ACP accès à des outils et services distants de confiance.", + "addConnection": "Ajouter une connexion", + "addMCPConnection": "Ajouter une connexion MCP", + "editMCPConnection": "Modifier la connexion MCP", + "mcpConnectionEditorDescription": "Configurez un serveur Streamable HTTP et une authentification facultative.", + "connectionName": "Nom de la connexion", + "mcpServerURL": "URL du serveur MCP", + "enableMCPConnection": "Activer pour les agents ACP", + "mcpBearerAuthentication": "Utiliser l’authentification Bearer", + "mcpBearerToken": "Jeton Bearer", + "mcpBearerTokenPlaceholder": "Saisissez le jeton Bearer", + "mcpBearerTokenRequired": "Saisissez un jeton Bearer avant d’activer cette connexion.", + "deleteMCPConnection": "Supprimer la connexion", + "deleteMCPConnectionDescription": "Supprimer cette connexion MCP et son jeton Bearer enregistré ?", + "noMCPConnections": "Aucune connexion MCP externe configurée.", "addModel": "Ajouter un modèle", "editModel": "Modifier le modèle", "modelEditorDescription": "Fournisseur, modèle, identifiants et capacités.", diff --git a/packages/vue/src/i18n/locales/it/dialogs.json b/packages/vue/src/i18n/locales/it/dialogs.json index 40d806bda..5c3b52606 100644 --- a/packages/vue/src/i18n/locales/it/dialogs.json +++ b/packages/vue/src/i18n/locales/it/dialogs.json @@ -87,6 +87,22 @@ "settingsAIAndAgents": "IA e agenti", "models": "Modelli", "modelsDescription": "Configura modelli riutilizzabili e le connessioni ai provider.", + "mcpConnections": "Connessioni MCP", + "mcpConnectionsDescription": "Consenti agli agenti ACP di accedere a strumenti e servizi remoti attendibili.", + "addConnection": "Aggiungi connessione", + "addMCPConnection": "Aggiungi connessione MCP", + "editMCPConnection": "Modifica connessione MCP", + "mcpConnectionEditorDescription": "Configura un server Streamable HTTP e l’autenticazione facoltativa.", + "connectionName": "Nome connessione", + "mcpServerURL": "URL server MCP", + "enableMCPConnection": "Abilita per gli agenti ACP", + "mcpBearerAuthentication": "Usa autenticazione Bearer", + "mcpBearerToken": "Token Bearer", + "mcpBearerTokenPlaceholder": "Inserisci il token Bearer", + "mcpBearerTokenRequired": "Inserisci un token Bearer prima di abilitare questa connessione.", + "deleteMCPConnection": "Elimina connessione", + "deleteMCPConnectionDescription": "Eliminare questa connessione MCP e il token Bearer salvato?", + "noMCPConnections": "Nessuna connessione MCP esterna configurata.", "addModel": "Aggiungi modello", "editModel": "Modifica modello", "modelEditorDescription": "Provider, modello, credenziali e funzionalità.", diff --git a/packages/vue/src/i18n/locales/ja/dialogs.json b/packages/vue/src/i18n/locales/ja/dialogs.json index e39e94748..eef0cd14d 100644 --- a/packages/vue/src/i18n/locales/ja/dialogs.json +++ b/packages/vue/src/i18n/locales/ja/dialogs.json @@ -87,6 +87,22 @@ "settingsAIAndAgents": "AIとエージェント", "models": "モデル", "modelsDescription": "再利用可能なモデルとプロバイダー接続を設定します。", + "mcpConnections": "MCP接続", + "mcpConnectionsDescription": "ACPエージェントに信頼できるリモートツールやサービスへのアクセスを許可します。", + "addConnection": "接続を追加", + "addMCPConnection": "MCP接続を追加", + "editMCPConnection": "MCP接続を編集", + "mcpConnectionEditorDescription": "Streamable HTTPサーバーと任意の認証を設定します。", + "connectionName": "接続名", + "mcpServerURL": "MCPサーバーURL", + "enableMCPConnection": "ACPエージェントで有効にする", + "mcpBearerAuthentication": "Bearer認証を使用", + "mcpBearerToken": "Bearerトークン", + "mcpBearerTokenPlaceholder": "Bearerトークンを入力", + "mcpBearerTokenRequired": "この接続を有効にする前にBearerトークンを入力してください。", + "deleteMCPConnection": "接続を削除", + "deleteMCPConnectionDescription": "このMCP接続と保存されたBearerトークンを削除しますか?", + "noMCPConnections": "外部MCP接続は設定されていません。", "addModel": "モデルを追加", "editModel": "モデルを編集", "modelEditorDescription": "プロバイダー、モデル、認証情報、機能。", diff --git a/packages/vue/src/i18n/locales/pl/dialogs.json b/packages/vue/src/i18n/locales/pl/dialogs.json index a0a2fe94a..2faf9107c 100644 --- a/packages/vue/src/i18n/locales/pl/dialogs.json +++ b/packages/vue/src/i18n/locales/pl/dialogs.json @@ -87,6 +87,22 @@ "settingsAIAndAgents": "AI i agenci", "models": "Modele", "modelsDescription": "Skonfiguruj modele wielokrotnego użytku i połączenia z dostawcami.", + "mcpConnections": "Połączenia MCP", + "mcpConnectionsDescription": "Zapewnij agentom ACP dostęp do zaufanych zdalnych narzędzi i usług.", + "addConnection": "Dodaj połączenie", + "addMCPConnection": "Dodaj połączenie MCP", + "editMCPConnection": "Edytuj połączenie MCP", + "mcpConnectionEditorDescription": "Skonfiguruj serwer Streamable HTTP i opcjonalne uwierzytelnianie.", + "connectionName": "Nazwa połączenia", + "mcpServerURL": "URL serwera MCP", + "enableMCPConnection": "Włącz dla agentów ACP", + "mcpBearerAuthentication": "Użyj uwierzytelniania Bearer", + "mcpBearerToken": "Token Bearer", + "mcpBearerTokenPlaceholder": "Wprowadź token Bearer", + "mcpBearerTokenRequired": "Wprowadź token Bearer przed włączeniem tego połączenia.", + "deleteMCPConnection": "Usuń połączenie", + "deleteMCPConnectionDescription": "Usunąć to połączenie MCP i zapisany token Bearer?", + "noMCPConnections": "Nie skonfigurowano zewnętrznych połączeń MCP.", "addModel": "Dodaj model", "editModel": "Edytuj model", "modelEditorDescription": "Dostawca, model, dane logowania i możliwości.", diff --git a/packages/vue/src/i18n/locales/ru/dialogs.json b/packages/vue/src/i18n/locales/ru/dialogs.json index f57398487..693d769bb 100644 --- a/packages/vue/src/i18n/locales/ru/dialogs.json +++ b/packages/vue/src/i18n/locales/ru/dialogs.json @@ -87,6 +87,22 @@ "settingsAIAndAgents": "ИИ и агенты", "models": "Модели", "modelsDescription": "Настройте переиспользуемые модели и подключения к провайдерам.", + "mcpConnections": "Подключения MCP", + "mcpConnectionsDescription": "Предоставьте ACP-агентам доступ к доверенным удалённым инструментам и сервисам.", + "addConnection": "Добавить подключение", + "addMCPConnection": "Добавить подключение MCP", + "editMCPConnection": "Изменить подключение MCP", + "mcpConnectionEditorDescription": "Настройте сервер Streamable HTTP и необязательную аутентификацию.", + "connectionName": "Название подключения", + "mcpServerURL": "URL сервера MCP", + "enableMCPConnection": "Включить для ACP-агентов", + "mcpBearerAuthentication": "Использовать Bearer-аутентификацию", + "mcpBearerToken": "Bearer-токен", + "mcpBearerTokenPlaceholder": "Введите Bearer-токен", + "mcpBearerTokenRequired": "Введите Bearer-токен перед включением этого подключения.", + "deleteMCPConnection": "Удалить подключение", + "deleteMCPConnectionDescription": "Удалить это подключение MCP и сохранённый Bearer-токен?", + "noMCPConnections": "Внешние подключения MCP не настроены.", "addModel": "Добавить модель", "editModel": "Изменить модель", "modelEditorDescription": "Провайдер, модель, учётные данные и возможности.", diff --git a/packages/vue/src/i18n/locales/zh-cn/dialogs.json b/packages/vue/src/i18n/locales/zh-cn/dialogs.json index 8d1349188..92b089508 100644 --- a/packages/vue/src/i18n/locales/zh-cn/dialogs.json +++ b/packages/vue/src/i18n/locales/zh-cn/dialogs.json @@ -87,6 +87,22 @@ "settingsAIAndAgents": "AI 和代理", "models": "模型", "modelsDescription": "配置可复用模型及其提供商连接。", + "mcpConnections": "MCP 连接", + "mcpConnectionsDescription": "允许 ACP 代理访问受信任的远程工具和服务。", + "addConnection": "添加连接", + "addMCPConnection": "添加 MCP 连接", + "editMCPConnection": "编辑 MCP 连接", + "mcpConnectionEditorDescription": "配置 Streamable HTTP 服务器和可选身份验证。", + "connectionName": "连接名称", + "mcpServerURL": "MCP 服务器 URL", + "enableMCPConnection": "为 ACP 代理启用", + "mcpBearerAuthentication": "使用 Bearer 身份验证", + "mcpBearerToken": "Bearer 令牌", + "mcpBearerTokenPlaceholder": "输入 Bearer 令牌", + "mcpBearerTokenRequired": "启用此连接前请输入 Bearer 令牌。", + "deleteMCPConnection": "删除连接", + "deleteMCPConnectionDescription": "删除此 MCP 连接及其保存的 Bearer 令牌?", + "noMCPConnections": "尚未配置外部 MCP 连接。", "addModel": "添加模型", "editModel": "编辑模型", "modelEditorDescription": "提供商、模型、凭据和能力。", diff --git a/packages/vue/src/i18n/messages/dialogs.ts b/packages/vue/src/i18n/messages/dialogs.ts index 25827256f..07a0d82f8 100644 --- a/packages/vue/src/i18n/messages/dialogs.ts +++ b/packages/vue/src/i18n/messages/dialogs.ts @@ -163,6 +163,22 @@ export const dialogMessageDefaults = { settingsAIAndAgents: 'AI & agents', models: 'Models', modelsDescription: 'Configure reusable models and their provider connections.', + mcpConnections: 'MCP connections', + mcpConnectionsDescription: 'Give ACP agents access to trusted remote tools and services.', + addConnection: 'Add connection', + addMCPConnection: 'Add MCP connection', + editMCPConnection: 'Edit MCP connection', + mcpConnectionEditorDescription: 'Configure a Streamable HTTP server and optional authentication.', + connectionName: 'Connection name', + mcpServerURL: 'MCP server URL', + enableMCPConnection: 'Enable for ACP agents', + mcpBearerAuthentication: 'Use bearer authentication', + mcpBearerToken: 'Bearer token', + mcpBearerTokenPlaceholder: 'Enter bearer token', + mcpBearerTokenRequired: 'Enter a bearer token before enabling this connection.', + deleteMCPConnection: 'Delete connection', + deleteMCPConnectionDescription: 'Delete this MCP connection and remove its saved bearer token?', + noMCPConnections: 'No external MCP connections configured.', addModel: 'Add model', editModel: 'Edit model', modelEditorDescription: 'Provider, model, credentials, and capabilities.', diff --git a/src/app/ai/acp/transport.ts b/src/app/ai/acp/transport.ts index 31be3b09a..a907288da 100644 --- a/src/app/ai/acp/transport.ts +++ b/src/app/ai/acp/transport.ts @@ -8,9 +8,10 @@ import type { } from '@agentclientprotocol/sdk' import type { ChatTransport, UIMessage, UIMessageChunk } from 'ai' -import { AUTOMATION_HTTP_PORT, type ACPAgentDef } from '@open-pencil/core/constants' +import type { ACPAgentDef } from '@open-pencil/core/constants' import SYSTEM_PROMPT from '@/app/ai/chat/system-prompt.md?raw' +import { buildACPMCPServers } from '@/app/integrations/mcp' import { mapUpdate } from './map-update' import { spawnACPProcess } from './process' @@ -271,16 +272,7 @@ export class ACPChatTransport implements ChatTransport { try { sessionResult = await connection.newSession({ cwd: this.cwd, - mcpServers: [ - { - type: 'http' as const, - name: 'open-pencil', - url: `http://127.0.0.1:${AUTOMATION_HTTP_PORT}/mcp`, - headers: automationAuthToken - ? [{ name: 'Authorization', value: `Bearer ${automationAuthToken}` }] - : [] - } - ] + mcpServers: await buildACPMCPServers({ authorizationToken: automationAuthToken }) }) } catch (e) { await child.kill().catch(() => undefined) diff --git a/src/app/integrations/mcp/index.ts b/src/app/integrations/mcp/index.ts new file mode 100644 index 000000000..63fcbbb33 --- /dev/null +++ b/src/app/integrations/mcp/index.ts @@ -0,0 +1,3 @@ +export * from './runtime' +export * from './store' +export type * from './types' diff --git a/src/app/integrations/mcp/runtime.ts b/src/app/integrations/mcp/runtime.ts new file mode 100644 index 000000000..5c75b6d1c --- /dev/null +++ b/src/app/integrations/mcp/runtime.ts @@ -0,0 +1,45 @@ +import type { McpServer } from '@agentclientprotocol/sdk' + +import { AUTOMATION_HTTP_PORT } from '@open-pencil/core/constants' + +import { appCredentialServices } from '@/app/settings/credentials/app' + +import { enabledMCPConnections } from './store' +import type { MCPConnection } from './types' + +export type BuiltInMCPServerOptions = { + authorizationToken: string | null +} + +export function builtInMCPServer(options: BuiltInMCPServerOptions): McpServer { + return { + type: 'http', + name: 'open-pencil', + url: `http://127.0.0.1:${AUTOMATION_HTTP_PORT}/mcp`, + headers: options.authorizationToken + ? [{ name: 'Authorization', value: `Bearer ${options.authorizationToken}` }] + : [] + } +} + +async function externalMCPServer(connection: MCPConnection): Promise { + const headers = [] + if (connection.authentication.type === 'bearer') { + const token = await appCredentialServices.resolver.resolve( + connection.authentication.credentialRef + ) + if (!token) throw new Error(`MCP connection "${connection.name}" needs a bearer token`) + headers.push({ name: 'Authorization', value: `Bearer ${token}` }) + } + return { + type: 'http', + name: connection.name, + url: connection.transport.url, + headers + } +} + +export async function buildACPMCPServers(options: BuiltInMCPServerOptions): Promise { + const external = await Promise.all(enabledMCPConnections.value.map(externalMCPServer)) + return [builtInMCPServer(options), ...external] +} diff --git a/src/app/integrations/mcp/storage.ts b/src/app/integrations/mcp/storage.ts new file mode 100644 index 000000000..16b16c077 --- /dev/null +++ b/src/app/integrations/mcp/storage.ts @@ -0,0 +1,21 @@ +import { StorageSerializers, useLocalStorage } from '@vueuse/core' +import { ref } from 'vue' + +import { IS_BROWSER } from '@open-pencil/core/constants' + +const MCP_CONNECTION_SETTINGS_KEY = 'open-pencil:mcp-connections' + +const connectionSettings = !IS_BROWSER + ? ref(null) + : useLocalStorage(MCP_CONNECTION_SETTINGS_KEY, null, { + serializer: StorageSerializers.object, + writeDefaults: false + }) + +export function readMCPConnectionSettingsStorage(): unknown { + return connectionSettings.value +} + +export function writeMCPConnectionSettingsStorage(value: unknown): void { + connectionSettings.value = value +} diff --git a/src/app/integrations/mcp/store.ts b/src/app/integrations/mcp/store.ts new file mode 100644 index 000000000..f1464c557 --- /dev/null +++ b/src/app/integrations/mcp/store.ts @@ -0,0 +1,186 @@ +import { computed, ref, toRaw, watch } from 'vue' + +import { appCredentialServices } from '@/app/settings/credentials/app' +import { credentialRef } from '@/app/settings/credentials/reference' +import type { CredentialRef, CredentialStatus } from '@/app/settings/credentials/types' + +import { readMCPConnectionSettingsStorage, writeMCPConnectionSettingsStorage } from './storage' +import type { + MCPAuthentication, + MCPConnection, + MCPConnectionDraft, + MCPConnectionID, + MCPConnectionSettings +} from './types' + +const MAX_NAME_LENGTH = 80 +const MAX_URL_LENGTH = 2048 +const MCP_CONNECTION_ID_PATTERN = /^mcp-[a-z0-9._-]{1,60}$/ + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isCredentialRef(value: unknown): value is CredentialRef { + return ( + isRecord(value) && + typeof value.integrationId === 'string' && + typeof value.profileId === 'string' && + typeof value.field === 'string' + ) +} + +function parseAuthentication(value: unknown, id: MCPConnectionID): MCPAuthentication { + if (!isRecord(value) || value.type !== 'bearer') return { type: 'none' } + const expectedReference = mcpConnectionCredentialRef(id) + if (!isCredentialRef(value.credentialRef)) return { type: 'none' } + const matchesExpected = + value.credentialRef.integrationId === expectedReference.integrationId && + value.credentialRef.profileId === expectedReference.profileId && + value.credentialRef.field === expectedReference.field + return matchesExpected ? { type: 'bearer', credentialRef: expectedReference } : { type: 'none' } +} + +function parseConnection(value: unknown): MCPConnection | null { + if (!isRecord(value) || !isRecord(value.transport)) return null + const id = typeof value.id === 'string' ? value.id : '' + const name = typeof value.name === 'string' ? value.name.trim() : '' + const url = typeof value.transport.url === 'string' ? value.transport.url.trim() : '' + if (!MCP_CONNECTION_ID_PATTERN.test(id) || !name || value.transport.type !== 'streamable-http') { + return null + } + try { + validateMCPConnectionURL(url) + } catch { + return null + } + const connectionId = id as MCPConnectionID + return { + id: connectionId, + name: name.slice(0, MAX_NAME_LENGTH), + enabled: value.enabled === true, + transport: { type: 'streamable-http', url }, + authentication: parseAuthentication(value.authentication, connectionId) + } +} + +export function parseMCPConnectionSettings(value: unknown): MCPConnectionSettings { + if (!isRecord(value) || value.version !== 1 || !Array.isArray(value.connections)) { + return { version: 1, connections: [] } + } + const connections: MCPConnection[] = [] + const ids = new Set() + const names = new Set() + for (const candidate of value.connections) { + const connection = parseConnection(candidate) + if (!connection || ids.has(connection.id)) continue + const normalizedName = connection.name.toLowerCase() + if (normalizedName === 'open-pencil' || names.has(normalizedName)) continue + ids.add(connection.id) + names.add(normalizedName) + connections.push(connection) + } + return { version: 1, connections } +} + +export function validateMCPConnectionURL(value: string): URL { + if (!value || value.length > MAX_URL_LENGTH) throw new Error('Enter a valid MCP server URL') + let url: URL + try { + url = new URL(value) + } catch { + throw new Error('Enter a valid MCP server URL') + } + if (url.username || url.password) throw new Error('MCP server URLs cannot contain credentials') + const loopback = + url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]' + if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) { + throw new Error('MCP servers must use HTTPS, except on loopback addresses') + } + return url +} + +export const mcpConnectionSettings = ref( + parseMCPConnectionSettings(readMCPConnectionSettingsStorage()) +) + +watch(mcpConnectionSettings, (settings) => writeMCPConnectionSettingsStorage(settings), { + deep: true +}) + +export const enabledMCPConnections = computed(() => + mcpConnectionSettings.value.connections.filter((connection) => connection.enabled) +) + +export function mcpConnectionCredentialRef(id: MCPConnectionID): CredentialRef { + return credentialRef('mcp', 'bearer-token', id) +} + +export function createMCPConnectionDraft(connection?: MCPConnection): MCPConnectionDraft { + return { + id: connection?.id ?? null, + name: connection?.name ?? '', + url: connection?.transport.url ?? '', + enabled: connection?.enabled ?? false, + authenticationType: connection?.authentication.type ?? 'none' + } +} + +export function saveMCPConnectionDraft(draft: MCPConnectionDraft): MCPConnection { + const name = draft.name.trim() + if (!name) throw new Error('Connection name is required') + if (name.length > MAX_NAME_LENGTH) throw new Error('Connection name is too long') + if (name.toLowerCase() === 'open-pencil') throw new Error('The open-pencil name is reserved') + const duplicateName = mcpConnectionSettings.value.connections.some( + (connection) => + connection.id !== draft.id && connection.name.toLowerCase() === name.toLowerCase() + ) + if (duplicateName) throw new Error('Connection names must be unique') + const url = validateMCPConnectionURL(draft.url.trim()).toString() + if (draft.id && !MCP_CONNECTION_ID_PATTERN.test(draft.id)) { + throw new Error('Connection ID is invalid') + } + const id = draft.id ?? (`mcp-${crypto.randomUUID()}` as MCPConnectionID) + const authentication: MCPAuthentication = + draft.authenticationType === 'bearer' + ? { type: 'bearer', credentialRef: mcpConnectionCredentialRef(id) } + : { type: 'none' } + const connection: MCPConnection = { + id, + name, + enabled: draft.enabled, + transport: { type: 'streamable-http', url }, + authentication + } + const index = mcpConnectionSettings.value.connections.findIndex((item) => item.id === id) + if (index === -1) mcpConnectionSettings.value.connections.push(connection) + else mcpConnectionSettings.value.connections[index] = connection + return connection +} + +export function setMCPConnectionCredential(id: MCPConnectionID, value: string): Promise { + const token = value.trim() + const reference = mcpConnectionCredentialRef(id) + return token + ? appCredentialServices.manager.set(reference, token) + : appCredentialServices.manager.clear(reference) +} + +export function mcpConnectionCredentialStatus(id: MCPConnectionID): Promise { + return appCredentialServices.manager.status(mcpConnectionCredentialRef(id)) +} + +export async function removeMCPConnection(id: MCPConnectionID): Promise { + await appCredentialServices.manager.clear(mcpConnectionCredentialRef(id)) + mcpConnectionSettings.value.connections = mcpConnectionSettings.value.connections.filter( + (connection) => connection.id !== id + ) +} + +export function replaceMCPConnectionSettings(settings: MCPConnectionSettings): void { + mcpConnectionSettings.value = structuredClone(settings) +} + +export function mcpConnectionSettingsSnapshot(): MCPConnectionSettings { + return structuredClone(toRaw(mcpConnectionSettings.value)) +} diff --git a/src/app/integrations/mcp/types.ts b/src/app/integrations/mcp/types.ts new file mode 100644 index 000000000..05429a7be --- /dev/null +++ b/src/app/integrations/mcp/types.ts @@ -0,0 +1,31 @@ +import type { CredentialRef } from '@/app/settings/credentials/types' + +export type MCPConnectionID = `mcp-${string}` + +export type MCPHTTPTransport = { + type: 'streamable-http' + url: string +} + +export type MCPAuthentication = { type: 'none' } | { type: 'bearer'; credentialRef: CredentialRef } + +export type MCPConnection = { + id: MCPConnectionID + name: string + enabled: boolean + transport: MCPHTTPTransport + authentication: MCPAuthentication +} + +export type MCPConnectionSettings = { + version: 1 + connections: MCPConnection[] +} + +export type MCPConnectionDraft = { + id: MCPConnectionID | null + name: string + url: string + enabled: boolean + authenticationType: MCPAuthentication['type'] +} diff --git a/src/app/settings/credentials/persistence.ts b/src/app/settings/credentials/persistence.ts index 6b60deb74..491c73c0d 100644 --- a/src/app/settings/credentials/persistence.ts +++ b/src/app/settings/credentials/persistence.ts @@ -2,6 +2,7 @@ import { AI_PROVIDERS } from '@open-pencil/core/constants' import { aiModelSettings, modelConnectionCredentialRef } from '@/app/ai/models' import { VECTORIZE_CREDENTIAL_REFS } from '@/app/editor/vectorize/credentials' +import { mcpConnectionSettings, mcpConnectionCredentialRef } from '@/app/integrations/mcp' import { storageCredentialRefs, storageProviderRegistry } from '@/app/integrations/storage' import { PEXELS_CREDENTIAL, @@ -33,6 +34,9 @@ export function appCredentialRefs(): CredentialRef[] { PEXELS_CREDENTIAL, UNSPLASH_CREDENTIAL, ...VECTORIZE_CREDENTIAL_REFS, + ...mcpConnectionSettings.value.connections.map((connection) => + mcpConnectionCredentialRef(connection.id) + ), ...storageCredentials ]) } diff --git a/src/components/settings/mcp/MCPConnectionsSection.vue b/src/components/settings/mcp/MCPConnectionsSection.vue new file mode 100644 index 000000000..88fb87841 --- /dev/null +++ b/src/components/settings/mcp/MCPConnectionsSection.vue @@ -0,0 +1,262 @@ + + + diff --git a/src/components/settings/models/ModelsPanel.vue b/src/components/settings/models/ModelsPanel.vue index 105c989ae..de5ad3b5f 100644 --- a/src/components/settings/models/ModelsPanel.vue +++ b/src/components/settings/models/ModelsPanel.vue @@ -6,6 +6,7 @@ import { ACP_AGENTS, AI_PROVIDERS } from '@open-pencil/core/constants' import { aiModelSettings, modelConnection, modelConnectionCredentialStatus } from '@/app/ai/models' import type { CredentialStatus } from '@/app/settings/credentials/types' +import MCPConnectionsSection from '@/components/settings/mcp/MCPConnectionsSection.vue' import ProfileEditor from '@/components/settings/models/ProfileEditor.vue' import RoleAssignments from '@/components/settings/models/RoleAssignments.vue' @@ -157,6 +158,8 @@ watch( + +

{{ dialogs.modelAssignments }}

diff --git a/src/components/settings/provider/ProviderSettingsField.vue b/src/components/settings/provider/ProviderSettingsField.vue index 0ab512a06..21dd342da 100644 --- a/src/components/settings/provider/ProviderSettingsField.vue +++ b/src/components/settings/provider/ProviderSettingsField.vue @@ -3,12 +3,13 @@ import AppTextButton from '@/components/ui/AppTextButton.vue' interface ProviderSettingsFieldProps { label: string + labelFor?: string clearLabel?: string } defineOptions({ inheritAttrs: false }) -const { label, clearLabel } = defineProps() +const { label, labelFor, clearLabel } = defineProps() const emit = defineEmits<{ clear: [] }>() @@ -16,7 +17,7 @@ const emit = defineEmits<{ clear: [] }>()