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:
Danila Poyarkov 2026-08-14 20:08:45 +03:00 committed by GitHub
parent b93ffea073
commit 39bc4d2eb1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 998 additions and 15 deletions

View file

@ -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

View file

@ -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:

View file

@ -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.",

View file

@ -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.",

View file

@ -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 lauthentification Bearer",
"mcpBearerToken": "Jeton Bearer",
"mcpBearerTokenPlaceholder": "Saisissez le jeton Bearer",
"mcpBearerTokenRequired": "Saisissez un jeton Bearer avant dactiver 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.",

View file

@ -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 lautenticazione 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à.",

View file

@ -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": "プロバイダー、モデル、認証情報、機能。",

View file

@ -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.",

View file

@ -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": "Провайдер, модель, учётные данные и возможности.",

View file

@ -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": "提供商、模型、凭据和能力。",

View file

@ -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.',

View file

@ -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)

View file

@ -0,0 +1,3 @@
export * from './runtime'
export * from './store'
export type * from './types'

View 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]
}

View 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
}

View 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))
}

View 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']
}

View file

@ -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
])
}

View 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>

View file

@ -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>

View file

@ -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>

View file

@ -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"

View file

@ -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"

View file

@ -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)

View 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'
)
})
})

View 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
}