feat(acp): add reusable MCP connections (#516)
* feat(acp): add reusable MCP connections - Store named Streamable HTTP connections separately from model providers - Keep bearer tokens in the credential manager and resolve them per ACP session - Add localized settings, validation, documentation, and focused coverage * test(acp): harden MCP connection workflow - Label credential inputs and confirm destructive connection removal - Exercise MCP server delivery through an in-memory ACP session - Extend the browser smoke test to cover accessible input and confirmation flows * fix(acp): validate MCP connection lifecycle - Keep non-browser storage initialization in memory and restore the German model copy - Reject persisted name collisions and invalid draft IDs - Preserve connections on credential failures and require credentials before enablement
This commit is contained in:
parent
b93ffea073
commit
39bc4d2eb1
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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à.",
|
||||
|
|
|
|||
|
|
@ -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": "プロバイダー、モデル、認証情報、機能。",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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": "Провайдер, модель, учётные данные и возможности.",
|
||||
|
|
|
|||
|
|
@ -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": "提供商、模型、凭据和能力。",
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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<UIMessage> {
|
|||
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)
|
||||
|
|
|
|||
3
src/app/integrations/mcp/index.ts
Normal file
3
src/app/integrations/mcp/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from './runtime'
|
||||
export * from './store'
|
||||
export type * from './types'
|
||||
45
src/app/integrations/mcp/runtime.ts
Normal file
45
src/app/integrations/mcp/runtime.ts
Normal file
|
|
@ -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<McpServer> {
|
||||
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<McpServer[]> {
|
||||
const external = await Promise.all(enabledMCPConnections.value.map(externalMCPServer))
|
||||
return [builtInMCPServer(options), ...external]
|
||||
}
|
||||
21
src/app/integrations/mcp/storage.ts
Normal file
21
src/app/integrations/mcp/storage.ts
Normal file
|
|
@ -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<unknown>(null)
|
||||
: useLocalStorage<unknown>(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
|
||||
}
|
||||
186
src/app/integrations/mcp/store.ts
Normal file
186
src/app/integrations/mcp/store.ts
Normal file
|
|
@ -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<string, unknown> {
|
||||
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<string>()
|
||||
const names = new Set<string>()
|
||||
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<MCPConnectionSettings>(
|
||||
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<void> {
|
||||
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<CredentialStatus> {
|
||||
return appCredentialServices.manager.status(mcpConnectionCredentialRef(id))
|
||||
}
|
||||
|
||||
export async function removeMCPConnection(id: MCPConnectionID): Promise<void> {
|
||||
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))
|
||||
}
|
||||
31
src/app/integrations/mcp/types.ts
Normal file
31
src/app/integrations/mcp/types.ts
Normal file
|
|
@ -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']
|
||||
}
|
||||
|
|
@ -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
|
||||
])
|
||||
}
|
||||
|
|
|
|||
262
src/components/settings/mcp/MCPConnectionsSection.vue
Normal file
262
src/components/settings/mcp/MCPConnectionsSection.vue
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import {
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogDescription,
|
||||
AlertDialogTitle
|
||||
} from 'reka-ui'
|
||||
import { useI18n } from '@open-pencil/vue'
|
||||
|
||||
import {
|
||||
createMCPConnectionDraft,
|
||||
mcpConnectionCredentialStatus,
|
||||
mcpConnectionSettings,
|
||||
removeMCPConnection,
|
||||
saveMCPConnectionDraft,
|
||||
setMCPConnectionCredential,
|
||||
type MCPConnectionDraft
|
||||
} from '@/app/integrations/mcp'
|
||||
import type { CredentialStatus } from '@/app/settings/credentials/types'
|
||||
import ProviderSettingsKeyField from '@/components/settings/provider/ProviderSettingsKeyField.vue'
|
||||
import AppInput from '@/components/ui/AppInput.vue'
|
||||
import AppSwitch from '@/components/ui/AppSwitch.vue'
|
||||
import { AppAlertDialogRoot, AppDialogBody, AppDialogFooter } from '@/components/ui/dialog'
|
||||
|
||||
const { dialogs } = useI18n()
|
||||
const editing = ref(false)
|
||||
const draft = ref<MCPConnectionDraft>(createMCPConnectionDraft())
|
||||
const tokenDraft = ref('')
|
||||
const tokenStatus = ref<CredentialStatus>('missing')
|
||||
const error = ref('')
|
||||
const deleteOpen = ref(false)
|
||||
|
||||
const savedConnection = computed(() =>
|
||||
draft.value.id
|
||||
? mcpConnectionSettings.value.connections.find((connection) => connection.id === draft.value.id)
|
||||
: undefined
|
||||
)
|
||||
|
||||
function startAdd(): void {
|
||||
draft.value = createMCPConnectionDraft()
|
||||
tokenDraft.value = ''
|
||||
tokenStatus.value = 'missing'
|
||||
error.value = ''
|
||||
editing.value = true
|
||||
}
|
||||
|
||||
async function startEdit(id: string): Promise<void> {
|
||||
const connection = mcpConnectionSettings.value.connections.find((item) => item.id === id)
|
||||
if (!connection) return
|
||||
draft.value = createMCPConnectionDraft(connection)
|
||||
tokenDraft.value = ''
|
||||
tokenStatus.value = await mcpConnectionCredentialStatus(connection.id)
|
||||
error.value = ''
|
||||
editing.value = true
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
error.value = ''
|
||||
try {
|
||||
if (
|
||||
draft.value.enabled &&
|
||||
draft.value.authenticationType === 'bearer' &&
|
||||
!tokenDraft.value.trim() &&
|
||||
tokenStatus.value !== 'configured'
|
||||
) {
|
||||
throw new Error(dialogs.value.mcpBearerTokenRequired)
|
||||
}
|
||||
const connection = saveMCPConnectionDraft(draft.value)
|
||||
if (draft.value.authenticationType === 'none') {
|
||||
await setMCPConnectionCredential(connection.id, '')
|
||||
} else if (tokenDraft.value.trim()) {
|
||||
await setMCPConnectionCredential(connection.id, tokenDraft.value)
|
||||
}
|
||||
editing.value = false
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : String(cause)
|
||||
}
|
||||
}
|
||||
|
||||
async function clearCredential(): Promise<void> {
|
||||
if (!draft.value.id) return
|
||||
error.value = ''
|
||||
try {
|
||||
await setMCPConnectionCredential(draft.value.id, '')
|
||||
draft.value.enabled = false
|
||||
saveMCPConnectionDraft(draft.value)
|
||||
tokenDraft.value = ''
|
||||
tokenStatus.value = 'missing'
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : String(cause)
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(): Promise<void> {
|
||||
if (!draft.value.id) return
|
||||
error.value = ''
|
||||
try {
|
||||
await removeMCPConnection(draft.value.id)
|
||||
deleteOpen.value = false
|
||||
editing.value = false
|
||||
} catch (cause) {
|
||||
deleteOpen.value = false
|
||||
error.value = cause instanceof Error ? cause.message : String(cause)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => draft.value.authenticationType,
|
||||
(type) => {
|
||||
if (type === 'none') tokenDraft.value = ''
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
tokenStatus.value = savedConnection.value ? 'configured' : 'missing'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="mt-5 border-t border-border pt-4" data-mcp-connections>
|
||||
<div v-if="editing" class="flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold text-surface">
|
||||
{{ draft.id ? dialogs.editMCPConnection : dialogs.addMCPConnection }}
|
||||
</h3>
|
||||
<p class="text-[10px] text-muted">{{ dialogs.mcpConnectionEditorDescription }}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="text-[10px] text-muted hover:text-surface"
|
||||
@click="editing = false"
|
||||
>
|
||||
{{ dialogs.back }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label class="flex flex-col gap-1 text-[10px] text-muted">
|
||||
{{ dialogs.connectionName }}
|
||||
<AppInput
|
||||
v-model="draft.name"
|
||||
tone="panel"
|
||||
size="sm"
|
||||
:aria-label="dialogs.connectionName"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-[10px] text-muted">
|
||||
{{ dialogs.mcpServerURL }}
|
||||
<AppInput
|
||||
v-model="draft.url"
|
||||
tone="panel"
|
||||
size="sm"
|
||||
:aria-label="dialogs.mcpServerURL"
|
||||
placeholder="https://example.com/mcp"
|
||||
/>
|
||||
</label>
|
||||
<AppSwitch v-model="draft.enabled" :label="dialogs.enableMCPConnection" />
|
||||
<AppSwitch
|
||||
:model-value="draft.authenticationType === 'bearer'"
|
||||
:label="dialogs.mcpBearerAuthentication"
|
||||
@update:model-value="draft.authenticationType = $event ? 'bearer' : 'none'"
|
||||
/>
|
||||
<ProviderSettingsKeyField
|
||||
v-if="draft.authenticationType === 'bearer'"
|
||||
v-model="tokenDraft"
|
||||
:label="dialogs.mcpBearerToken"
|
||||
input-id="mcp-bearer-token"
|
||||
:saved="tokenStatus === 'configured'"
|
||||
kind="api"
|
||||
:placeholder="
|
||||
tokenStatus === 'configured' ? dialogs.keySavedReplace : dialogs.mcpBearerTokenPlaceholder
|
||||
"
|
||||
@clear="clearCredential"
|
||||
/>
|
||||
|
||||
<p v-if="error" class="text-[10px] text-danger" role="alert">{{ error }}</p>
|
||||
<div class="flex items-center justify-between">
|
||||
<button
|
||||
v-if="draft.id"
|
||||
type="button"
|
||||
class="text-[10px] text-danger hover:underline"
|
||||
@click="deleteOpen = true"
|
||||
>
|
||||
{{ dialogs.deleteMCPConnection }}
|
||||
</button>
|
||||
<span v-else />
|
||||
<button
|
||||
type="button"
|
||||
class="rounded bg-accent px-2.5 py-1.5 text-[11px] font-medium text-white hover:bg-accent/90"
|
||||
@click="save"
|
||||
>
|
||||
{{ dialogs.save }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold text-surface">{{ dialogs.mcpConnections }}</h3>
|
||||
<p class="text-[10px] text-muted">{{ dialogs.mcpConnectionsDescription }}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1 rounded bg-panel px-2 py-1 text-[10px] text-surface hover:bg-hover"
|
||||
@click="startAdd"
|
||||
>
|
||||
<icon-lucide-plus class="size-3" />
|
||||
{{ dialogs.addConnection }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="mcpConnectionSettings.connections.length" class="flex flex-col gap-1.5">
|
||||
<button
|
||||
v-for="connection in mcpConnectionSettings.connections"
|
||||
:key="connection.id"
|
||||
type="button"
|
||||
class="flex items-center gap-2 rounded border border-border bg-panel-field px-3 py-2 text-left hover:bg-panel-field-hover"
|
||||
@click="startEdit(connection.id)"
|
||||
>
|
||||
<icon-lucide-plug class="size-3.5 text-muted" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-[11px] font-medium text-surface">{{ connection.name }}</p>
|
||||
<p class="truncate text-[10px] text-muted">{{ connection.transport.url }}</p>
|
||||
</div>
|
||||
<span class="text-[9px] text-muted">
|
||||
{{ connection.enabled ? dialogs.enabled : dialogs.disabled }}
|
||||
</span>
|
||||
<icon-lucide-chevron-right class="size-3.5 text-muted" />
|
||||
</button>
|
||||
</div>
|
||||
<p v-else class="rounded border border-dashed border-border p-3 text-[10px] text-muted">
|
||||
{{ dialogs.noMCPConnections }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<AppAlertDialogRoot v-model:open="deleteOpen">
|
||||
<div class="border-b border-border px-4 py-3">
|
||||
<AlertDialogTitle class="text-sm font-semibold text-surface">
|
||||
{{ dialogs.deleteMCPConnection }}
|
||||
</AlertDialogTitle>
|
||||
</div>
|
||||
<AppDialogBody>
|
||||
<AlertDialogDescription class="text-xs text-muted">
|
||||
{{ dialogs.deleteMCPConnectionDescription }}
|
||||
</AlertDialogDescription>
|
||||
</AppDialogBody>
|
||||
<AppDialogFooter>
|
||||
<AlertDialogCancel as-child>
|
||||
<button class="rounded px-3 py-1.5 text-xs text-muted hover:bg-hover">
|
||||
{{ dialogs.cancel }}
|
||||
</button>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction as-child>
|
||||
<button class="rounded bg-danger px-3 py-1.5 text-xs text-white" @click="remove">
|
||||
{{ dialogs.deleteMCPConnection }}
|
||||
</button>
|
||||
</AlertDialogAction>
|
||||
</AppDialogFooter>
|
||||
</AppAlertDialogRoot>
|
||||
</template>
|
||||
|
|
@ -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(
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<MCPConnectionsSection />
|
||||
|
||||
<section class="mt-5 border-t border-border pt-4">
|
||||
<div class="mb-3">
|
||||
<h3 class="text-xs font-semibold text-surface">{{ dialogs.modelAssignments }}</h3>
|
||||
|
|
|
|||
|
|
@ -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<ProviderSettingsFieldProps>()
|
||||
const { label, labelFor, clearLabel } = defineProps<ProviderSettingsFieldProps>()
|
||||
|
||||
const emit = defineEmits<{ clear: [] }>()
|
||||
</script>
|
||||
|
|
@ -16,7 +17,7 @@ const emit = defineEmits<{ clear: [] }>()
|
|||
<template>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-[10px] text-muted">{{ label }}</label>
|
||||
<label :for="labelFor" class="text-[10px] text-muted">{{ label }}</label>
|
||||
<AppTextButton v-if="clearLabel" v-bind="$attrs" @click="emit('clear')">
|
||||
{{ clearLabel }}
|
||||
</AppTextButton>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import AppInput from '@/components/ui/AppInput.vue'
|
||||
|
||||
interface ProviderSettingsInputProps {
|
||||
id?: string
|
||||
type?: 'text' | 'password' | 'number'
|
||||
placeholder?: string
|
||||
min?: number
|
||||
|
|
@ -9,7 +10,7 @@ interface ProviderSettingsInputProps {
|
|||
step?: number
|
||||
}
|
||||
|
||||
const { type = 'text', placeholder, min, max, step } = defineProps<ProviderSettingsInputProps>()
|
||||
const { id, type = 'text', placeholder, min, max, step } = defineProps<ProviderSettingsInputProps>()
|
||||
|
||||
const modelValue = defineModel<string | number>({ required: true })
|
||||
const emit = defineEmits<{ change: [] }>()
|
||||
|
|
@ -18,6 +19,7 @@ const emit = defineEmits<{ change: [] }>()
|
|||
<template>
|
||||
<AppInput
|
||||
v-model="modelValue"
|
||||
:id="id"
|
||||
:type="type"
|
||||
:placeholder="placeholder"
|
||||
:min="min"
|
||||
|
|
|
|||
|
|
@ -7,12 +7,13 @@ import ProviderSettingsField from '@/components/settings/provider/ProviderSettin
|
|||
import ProviderSettingsInput from '@/components/settings/provider/ProviderSettingsInput.vue'
|
||||
import ProviderSettingsLink from '@/components/settings/provider/ProviderSettingsLink.vue'
|
||||
|
||||
const { label, modelValue, saved, kind, placeholder, keyURL, keyURLLabel } = defineProps<{
|
||||
const { label, modelValue, saved, kind, placeholder, keyURL, keyURLLabel, inputId } = defineProps<{
|
||||
label: string
|
||||
modelValue: string
|
||||
saved: boolean
|
||||
kind: 'api' | 'pexels' | 'unsplash'
|
||||
placeholder: string
|
||||
inputId?: string
|
||||
keyURL?: string
|
||||
keyURLLabel?: string
|
||||
}>()
|
||||
|
|
@ -41,12 +42,14 @@ const clearDataTestId = computed(() => {
|
|||
<template>
|
||||
<ProviderSettingsField
|
||||
:label="label"
|
||||
:label-for="inputId"
|
||||
:clear-label="saved ? dialogs.clear : undefined"
|
||||
:data-test-id="clearDataTestId"
|
||||
@clear="emit('clear')"
|
||||
>
|
||||
<ProviderSettingsInput
|
||||
:model-value="modelValue"
|
||||
:id="inputId"
|
||||
type="password"
|
||||
:data-test-id="inputDataTestId"
|
||||
:placeholder="placeholder"
|
||||
|
|
|
|||
|
|
@ -34,6 +34,44 @@ test('storage settings keep secrets behind the credential manager', async ({ pag
|
|||
await expect(secretField.locator('input')).not.toHaveAttribute('placeholder', /Key saved/)
|
||||
})
|
||||
|
||||
test('MCP connections keep bearer tokens out of ordinary settings', async ({ page }) => {
|
||||
await page.goto('/?test')
|
||||
const canvas = new CanvasHelper(page)
|
||||
await canvas.waitForInit()
|
||||
|
||||
await page.getByTestId('app-settings-trigger').click()
|
||||
const section = page.locator('[data-mcp-connections]')
|
||||
await section.getByRole('button', { name: 'Add connection' }).click()
|
||||
await section.getByLabel('Connection name').fill('GitHub')
|
||||
await section.getByLabel('MCP server URL').fill('http://example.com/mcp')
|
||||
await section.getByRole('button', { name: 'Save' }).click()
|
||||
await expect(section.getByRole('alert')).toContainText('must use HTTPS')
|
||||
|
||||
await section.getByLabel('MCP server URL').fill('https://example.com/mcp')
|
||||
await section.getByRole('switch', { name: 'Enable for ACP agents' }).click()
|
||||
await section.getByRole('switch', { name: 'Use bearer authentication' }).click()
|
||||
await section.getByLabel('Bearer token').fill('secret-mcp-token')
|
||||
await section.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
await expect(section).toContainText('GitHub')
|
||||
await expect(section).toContainText('Enabled')
|
||||
await expect(section).not.toContainText('secret-mcp-token')
|
||||
|
||||
await page.getByTestId('app-settings-done').click()
|
||||
await page.reload()
|
||||
await canvas.waitForInit()
|
||||
await page.getByTestId('app-settings-trigger').click()
|
||||
await expect(section).toContainText('https://example.com/mcp')
|
||||
await expect(section).toContainText('Enabled')
|
||||
await section.getByRole('button', { name: /GitHub/ }).click()
|
||||
await expect(section.getByPlaceholder(/Key saved/)).toBeVisible()
|
||||
await section.getByRole('button', { name: 'Delete connection' }).click()
|
||||
const confirmation = page.getByRole('alertdialog')
|
||||
await expect(confirmation).toContainText('remove its saved bearer token')
|
||||
await confirmation.getByRole('button', { name: 'Delete connection' }).click()
|
||||
await expect(section).toContainText('No external MCP connections configured')
|
||||
})
|
||||
|
||||
test('model library keeps reusable profiles and role assignments', async ({ page }) => {
|
||||
await page.goto('/?test')
|
||||
const canvas = new CanvasHelper(page)
|
||||
|
|
|
|||
185
tests/engine/app/integrations/mcp-connections.test.ts
Normal file
185
tests/engine/app/integrations/mcp-connections.test.ts
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import { afterEach, beforeEach, describe, expect, test, vi } from 'bun:test'
|
||||
|
||||
import {
|
||||
buildACPMCPServers,
|
||||
createMCPConnectionDraft,
|
||||
mcpConnectionSettingsSnapshot,
|
||||
parseMCPConnectionSettings,
|
||||
removeMCPConnection,
|
||||
replaceMCPConnectionSettings,
|
||||
saveMCPConnectionDraft,
|
||||
setMCPConnectionCredential,
|
||||
validateMCPConnectionURL,
|
||||
type MCPConnectionSettings
|
||||
} from '@/app/integrations/mcp'
|
||||
import { appCredentialServices } from '@/app/settings/credentials/app'
|
||||
|
||||
import { captureACPSessionMCPServers } from '#tests/helpers/mcp/acp-session'
|
||||
|
||||
let original: MCPConnectionSettings
|
||||
|
||||
beforeEach(() => {
|
||||
original = mcpConnectionSettingsSnapshot()
|
||||
replaceMCPConnectionSettings({ version: 1, connections: [] })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
replaceMCPConnectionSettings(original)
|
||||
})
|
||||
|
||||
describe('MCP connections', () => {
|
||||
test('accepts HTTPS and loopback HTTP endpoints only', () => {
|
||||
expect(validateMCPConnectionURL('https://example.com/mcp').hostname).toBe('example.com')
|
||||
expect(validateMCPConnectionURL('http://127.0.0.1:3333/mcp').port).toBe('3333')
|
||||
expect(() => validateMCPConnectionURL('http://example.com/mcp')).toThrow('HTTPS')
|
||||
expect(() => validateMCPConnectionURL('https://user:secret@example.com/mcp')).toThrow(
|
||||
'credentials'
|
||||
)
|
||||
})
|
||||
|
||||
test('repairs malformed, reserved, and duplicate persisted connections', () => {
|
||||
expect(parseMCPConnectionSettings(null)).toEqual({ version: 1, connections: [] })
|
||||
expect(
|
||||
parseMCPConnectionSettings({
|
||||
version: 1,
|
||||
connections: [
|
||||
{
|
||||
id: 'mcp-valid',
|
||||
name: 'Valid',
|
||||
enabled: true,
|
||||
transport: { type: 'streamable-http', url: 'https://example.com/mcp' },
|
||||
authentication: { type: 'none' }
|
||||
},
|
||||
{
|
||||
id: 'mcp-reserved',
|
||||
name: 'Open-Pencil',
|
||||
enabled: true,
|
||||
transport: { type: 'streamable-http', url: 'https://reserved.example.com/mcp' },
|
||||
authentication: { type: 'none' }
|
||||
},
|
||||
{
|
||||
id: 'mcp-duplicate-name',
|
||||
name: 'valid',
|
||||
enabled: true,
|
||||
transport: { type: 'streamable-http', url: 'https://duplicate.example.com/mcp' },
|
||||
authentication: { type: 'none' }
|
||||
},
|
||||
{
|
||||
id: 'mcp-../../other-integration',
|
||||
name: 'Unsafe credential reference',
|
||||
enabled: true,
|
||||
transport: { type: 'streamable-http', url: 'https://example.com/mcp' },
|
||||
authentication: { type: 'bearer' }
|
||||
},
|
||||
{
|
||||
id: 'mcp-invalid',
|
||||
name: 'Invalid',
|
||||
enabled: true,
|
||||
transport: { type: 'streamable-http', url: 'file:///tmp/mcp' }
|
||||
}
|
||||
]
|
||||
}).connections.map((connection) => connection.id)
|
||||
).toEqual(['mcp-valid'])
|
||||
})
|
||||
|
||||
test('defaults new connections to disabled and reserves unique names', async () => {
|
||||
const draft = createMCPConnectionDraft()
|
||||
expect(draft.enabled).toBeFalse()
|
||||
draft.name = 'GitHub'
|
||||
draft.url = 'https://example.com/mcp'
|
||||
const connection = saveMCPConnectionDraft(draft)
|
||||
expect(connection.enabled).toBeFalse()
|
||||
expect(await buildACPMCPServers({ authorizationToken: null })).toHaveLength(1)
|
||||
|
||||
const duplicate = createMCPConnectionDraft()
|
||||
duplicate.name = 'github'
|
||||
duplicate.url = 'https://other.example.com/mcp'
|
||||
expect(() => saveMCPConnectionDraft(duplicate)).toThrow('unique')
|
||||
duplicate.name = 'open-pencil'
|
||||
expect(() => saveMCPConnectionDraft(duplicate)).toThrow('reserved')
|
||||
|
||||
duplicate.id = 'mcp-../../unsafe' as typeof duplicate.id
|
||||
duplicate.name = 'Safe name'
|
||||
expect(() => saveMCPConnectionDraft(duplicate)).toThrow('ID is invalid')
|
||||
})
|
||||
|
||||
test('composes enabled external servers after the built-in server', async () => {
|
||||
const draft = createMCPConnectionDraft()
|
||||
draft.name = 'GitHub'
|
||||
draft.url = 'https://example.com/mcp'
|
||||
draft.enabled = true
|
||||
draft.authenticationType = 'bearer'
|
||||
const connection = saveMCPConnectionDraft(draft)
|
||||
try {
|
||||
await setMCPConnectionCredential(connection.id, 'secret-token')
|
||||
expect(await buildACPMCPServers({ authorizationToken: 'built-in-token' })).toEqual([
|
||||
{
|
||||
type: 'http',
|
||||
name: 'open-pencil',
|
||||
url: expect.stringContaining('/mcp'),
|
||||
headers: [{ name: 'Authorization', value: 'Bearer built-in-token' }]
|
||||
},
|
||||
{
|
||||
type: 'http',
|
||||
name: 'GitHub',
|
||||
url: 'https://example.com/mcp',
|
||||
headers: [{ name: 'Authorization', value: 'Bearer secret-token' }]
|
||||
}
|
||||
])
|
||||
} finally {
|
||||
await setMCPConnectionCredential(connection.id, '')
|
||||
}
|
||||
})
|
||||
|
||||
test('keeps the connection when credential deletion fails', async () => {
|
||||
const draft = createMCPConnectionDraft()
|
||||
draft.name = 'Protected connection'
|
||||
draft.url = 'https://example.com/mcp'
|
||||
const connection = saveMCPConnectionDraft(draft)
|
||||
vi.spyOn(appCredentialServices.manager, 'clear').mockRejectedValue(
|
||||
new Error('Credential store unavailable')
|
||||
)
|
||||
|
||||
await expect(removeMCPConnection(connection.id)).rejects.toThrow('unavailable')
|
||||
expect(mcpConnectionSettingsSnapshot().connections.map((item) => item.id)).toContain(
|
||||
connection.id
|
||||
)
|
||||
})
|
||||
|
||||
test('delivers built-in and external server configuration over a real ACP session', async () => {
|
||||
const draft = createMCPConnectionDraft()
|
||||
draft.name = 'Smoke server'
|
||||
draft.url = 'https://smoke.example.com/mcp'
|
||||
draft.enabled = true
|
||||
saveMCPConnectionDraft(draft)
|
||||
|
||||
expect(await captureACPSessionMCPServers()).toEqual([
|
||||
{
|
||||
type: 'http',
|
||||
name: 'open-pencil',
|
||||
url: expect.stringContaining('/mcp'),
|
||||
headers: [{ name: 'Authorization', value: 'Bearer built-in-token' }]
|
||||
},
|
||||
{
|
||||
type: 'http',
|
||||
name: 'Smoke server',
|
||||
url: 'https://smoke.example.com/mcp',
|
||||
headers: []
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
test('fails clearly when an enabled authenticated connection has no token', async () => {
|
||||
const draft = createMCPConnectionDraft()
|
||||
draft.name = 'Missing token'
|
||||
draft.url = 'https://example.com/mcp'
|
||||
draft.enabled = true
|
||||
draft.authenticationType = 'bearer'
|
||||
saveMCPConnectionDraft(draft)
|
||||
|
||||
await expect(buildACPMCPServers({ authorizationToken: null })).rejects.toThrow(
|
||||
'needs a bearer token'
|
||||
)
|
||||
})
|
||||
})
|
||||
53
tests/helpers/mcp/acp-session.ts
Normal file
53
tests/helpers/mcp/acp-session.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import {
|
||||
AgentSideConnection,
|
||||
ClientSideConnection,
|
||||
PROTOCOL_VERSION,
|
||||
type Agent,
|
||||
type Client,
|
||||
type McpServer,
|
||||
type NewSessionRequest
|
||||
} from '@agentclientprotocol/sdk'
|
||||
|
||||
import { buildACPMCPServers } from '@/app/integrations/mcp'
|
||||
|
||||
function pairedStreams() {
|
||||
const clientToAgent = new TransformStream()
|
||||
const agentToClient = new TransformStream()
|
||||
return {
|
||||
client: { writable: clientToAgent.writable, readable: agentToClient.readable },
|
||||
agent: { writable: agentToClient.writable, readable: clientToAgent.readable }
|
||||
}
|
||||
}
|
||||
|
||||
export async function captureACPSessionMCPServers(): Promise<McpServer[]> {
|
||||
const streams = pairedStreams()
|
||||
let received: NewSessionRequest | null = null
|
||||
const client: Client = {
|
||||
requestPermission: async () => ({ outcome: { outcome: 'cancelled' } }),
|
||||
sessionUpdate: async () => undefined
|
||||
}
|
||||
const agent: Agent = {
|
||||
initialize: async () => ({
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
agentCapabilities: { loadSession: false }
|
||||
}),
|
||||
newSession: async (params) => {
|
||||
received = params
|
||||
return { sessionId: 'smoke-session' }
|
||||
},
|
||||
authenticate: async () => ({}),
|
||||
prompt: async () => ({ stopReason: 'end_turn' }),
|
||||
cancel: async () => undefined
|
||||
}
|
||||
|
||||
const agentConnection = new AgentSideConnection(() => agent, streams.agent)
|
||||
const connection = new ClientSideConnection(() => client, streams.client)
|
||||
await connection.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
await connection.newSession({
|
||||
cwd: '/tmp',
|
||||
mcpServers: await buildACPMCPServers({ authorizationToken: 'built-in-token' })
|
||||
})
|
||||
if (!received) throw new Error('ACP agent did not receive session configuration')
|
||||
void agentConnection
|
||||
return received.mcpServers
|
||||
}
|
||||
Loading…
Reference in a new issue